{"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[3723ef77] Delete notes.tdsl\n\nSide A — unified diff (full patch):\ndiff --git a/notes.tdsl b/notes.tdsl\ndeleted file mode 100644\nindex 067951bfdaa10b9c450d23bd1095a68f769f1e81..0000000000000000000000000000000000000000\n--- a/notes.tdsl\n+++ /dev/null\n@@ -1,32 +0,0 @@\n-don't truncate ever on post page\n-delete button should be in corner, expandable? and should work lol. errors are not being morphed properly, no error div.\n-disabled a class doesn't work\n-should have a tiny bit 3d \n-vote history needs padding around its body. and cursor pointer on the conrner expand button\n-post page shouldn't have h3, it should have link to self on breadcrumb. the breadcrumb is the title\n-\n-{{dark = time-ordered · light = vote-ranked }} should go at the very bottom as a footer/mantra, on all pages, below the cli link. or just in the controls next to theme switcher.\n-\n-post page doesn't have login header layout. garden doesn' thave matching login screne, breadcrumb strucutre... nede to have a validator or something to make that sync\n-\n-post prose should be serif. other fonts should be more consistent..\n-\n-room page needs 2 fewer headers lol\n-\n-need a consistent style for \"collections\". not sure if it sohuld have border... \n-\n-frontpage\n- - \"your rooms\" haeder above \n- - \n-\n-\n-~ in garden inside a room says \"~/unranked among siblings\" which is wrong\n-maybe root item body should be hardcoded to flower? you can't define a body for root in the syntax i don't think\n-\n-need consistent + in corener of section header that morphs/expands in add form. i think morph. you can only see the form if you're logged in. \n-\n-my json-encoded plan is not being used for the forms. i want that same plan for the morphable/expandable + signs that produce forms. form target encoded... there was a plan.md with this. plan2.md...\n-\n-new public thread form needs fewer words\n-\n-new thread errors don't propogate properly. no #errors div, it used to be more precise? maybe it should be more precise?\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[3908604c] reshuffle\n\nSide B — unified diff (full patch):\ndiff --git a/.dockerignore b/.dockerignore\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2793eb9c61883eaeb6cc2335eb06b196ae96a0d4\n--- /dev/null\n+++ b/.dockerignore\n@@ -0,0 +1,7 @@\n+target/\n+.git/\n+.cpcache/\n+.clj-kondo/\n+legacy/\n+*.tdsl\n+test/\ndiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..083345813f8760b936291c0d171fc2b4ce743424\n--- /dev/null\n+++ b/.github/workflows/ci.yml\n@@ -0,0 +1,34 @@\n+name: CI\n+\n+on:\n+ pull_request:\n+ push:\n+ branches: [main]\n+ workflow_dispatch: {}\n+\n+concurrency:\n+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n+ cancel-in-progress: true\n+\n+permissions:\n+ contents: read\n+\n+jobs:\n+ test:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v4\n+\n+ - uses: dtolnay/rust-toolchain@stable\n+\n+ - name: Rust tests\n+ run: cargo test --all\n+\n+ - uses: DeLaGuardo/setup-clojure@12.5\n+ with:\n+ cli: \"1.12.0.1530\"\n+\n+ - name: Clojure tests (Kaocha)\n+ run: |\n+ mkdir -p target\n+ clojure -M:kaocha\ndiff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..24c07a3875d6a92e90eff5f67def0a718f5f2f0f\n--- /dev/null\n+++ b/.github/workflows/deploy.yml\n@@ -0,0 +1,30 @@\n+name: Deploy (Fly.io)\n+\n+on:\n+ push:\n+ branches: [main]\n+ paths:\n+ - \"server/**\"\n+ - \"Cargo.toml\"\n+ - \"Cargo.lock\"\n+ - \"Dockerfile\"\n+ - \"fly.toml\"\n+ - \".github/workflows/deploy.yml\"\n+ workflow_dispatch: {}\n+\n+concurrency:\n+ group: fly-deploy-sorter2-main\n+ cancel-in-progress: true\n+\n+jobs:\n+ deploy:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v4\n+\n+ - uses: superfly/flyctl-actions/setup-flyctl@master\n+\n+ - name: Deploy sorter2\n+ env:\n+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n+ run: flyctl deploy --remote-only\ndiff --git a/.gitignore b/.gitignore\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..16de5edb7185b04ef5bc64512814d7dfe2c1f50c\n--- /dev/null\n+++ b/.gitignore\n@@ -0,0 +1,6 @@\n+/target/\n+/.cpcache/\n+/.clj-kondo/\n+/.lsp/\n+*.swp\n+.DS_Store\ndiff --git a/Cargo.lock b/Cargo.lock\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..266e876bb7ccbe788beb1d5bd53ad5b45ee5825b\n--- /dev/null\n+++ b/Cargo.lock\n@@ -0,0 +1,2088 @@\n+# This file is automatically @generated by Cargo.\n+# It is not intended for manual editing.\n+version = 4\n+\n+[[package]]\n+name = \"aho-corasick\"\n+version = \"1.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301\"\n+dependencies = [\n+ \"memchr\",\n+]\n+\n+[[package]]\n+name = \"anyhow\"\n+version = \"1.0.102\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c\"\n+\n+[[package]]\n+name = \"async-trait\"\n+version = \"0.1.89\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"atomic-waker\"\n+version = \"1.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0\"\n+\n+[[package]]\n+name = \"axum\"\n+version = \"0.7.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f\"\n+dependencies = [\n+ \"async-trait\",\n+ \"axum-core\",\n+ \"axum-macros\",\n+ \"bytes\",\n+ \"futures-util\",\n+ \"http\",\n+ \"http-body\",\n+ \"http-body-util\",\n+ \"hyper\",\n+ \"hyper-util\",\n+ \"itoa\",\n+ \"matchit\",\n+ \"memchr\",\n+ \"mime\",\n+ \"percent-encoding\",\n+ \"pin-project-lite\",\n+ \"rustversion\",\n+ \"serde\",\n+ \"serde_json\",\n+ \"serde_path_to_error\",\n+ \"serde_urlencoded\",\n+ \"sync_wrapper\",\n+ \"tokio\",\n+ \"tower\",\n+ \"tower-layer\",\n+ \"tower-service\",\n+ \"tracing\",\n+]\n+\n+[[package]]\n+name = \"axum-core\"\n+version = \"0.4.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199\"\n+dependencies = [\n+ \"async-trait\",\n+ \"bytes\",\n+ \"futures-util\",\n+ \"http\",\n+ \"http-body\",\n+ \"http-body-util\",\n+ \"mime\",\n+ \"pin-project-lite\",\n+ \"rustversion\",\n+ \"sync_wrapper\",\n+ \"tower-layer\",\n+ \"tower-service\",\n+ \"tracing\",\n+]\n+\n+[[package]]\n+name = \"axum-extra\"\n+version = \"0.9.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04\"\n+dependencies = [\n+ \"axum\",\n+ \"axum-core\",\n+ \"bytes\",\n+ \"cookie\",\n+ \"fastrand\",\n+ \"futures-util\",\n+ \"http\",\n+ \"http-body\",\n+ \"http-body-util\",\n+ \"mime\",\n+ \"multer\",\n+ \"pin-project-lite\",\n+ \"serde\",\n+ \"tower\",\n+ \"tower-layer\",\n+ \"tower-service\",\n+]\n+\n+[[package]]\n+name = \"axum-macros\"\n+version = \"0.4.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"base64\"\n+version = \"0.22.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"2.11.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3\"\n+\n+[[package]]\n+name = \"bumpalo\"\n+version = \"3.20.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649\"\n+\n+[[package]]\n+name = \"bytes\"\n+version = \"1.11.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33\"\n+\n+[[package]]\n+name = \"cc\"\n+version = \"1.2.62\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\"\n+dependencies = [\n+ \"find-msvc-tools\",\n+ \"shlex\",\n+]\n+\n+[[package]]\n+name = \"cfg-if\"\n+version = \"1.0.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"\n+\n+[[package]]\n+name = \"cookie\"\n+version = \"0.18.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747\"\n+dependencies = [\n+ \"percent-encoding\",\n+ \"time\",\n+ \"version_check\",\n+]\n+\n+[[package]]\n+name = \"core-foundation\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f\"\n+dependencies = [\n+ \"core-foundation-sys\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"core-foundation\"\n+version = \"0.10.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6\"\n+dependencies = [\n+ \"core-foundation-sys\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"core-foundation-sys\"\n+version = \"0.8.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b\"\n+\n+[[package]]\n+name = \"deranged\"\n+version = \"0.5.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c\"\n+dependencies = [\n+ \"powerfmt\",\n+]\n+\n+[[package]]\n+name = \"displaydoc\"\n+version = \"0.2.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"encoding_rs\"\n+version = \"0.8.35\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3\"\n+dependencies = [\n+ \"cfg-if\",\n+]\n+\n+[[package]]\n+name = \"equivalent\"\n+version = \"1.0.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f\"\n+\n+[[package]]\n+name = \"errno\"\n+version = \"0.3.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb\"\n+dependencies = [\n+ \"libc\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"fastrand\"\n+version = \"2.4.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6\"\n+\n+[[package]]\n+name = \"find-msvc-tools\"\n+version = \"0.1.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582\"\n+\n+[[package]]\n+name = \"fnv\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1\"\n+\n+[[package]]\n+name = \"foldhash\"\n+version = \"0.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2\"\n+\n+[[package]]\n+name = \"foreign-types\"\n+version = \"0.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1\"\n+dependencies = [\n+ \"foreign-types-shared\",\n+]\n+\n+[[package]]\n+name = \"foreign-types-shared\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b\"\n+\n+[[package]]\n+name = \"form_urlencoded\"\n+version = \"1.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf\"\n+dependencies = [\n+ \"percent-encoding\",\n+]\n+\n+[[package]]\n+name = \"futures-channel\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d\"\n+dependencies = [\n+ \"futures-core\",\n+]\n+\n+[[package]]\n+name = \"futures-core\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d\"\n+\n+[[package]]\n+name = \"futures-sink\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893\"\n+\n+[[package]]\n+name = \"futures-task\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393\"\n+\n+[[package]]\n+name = \"futures-util\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6\"\n+dependencies = [\n+ \"futures-core\",\n+ \"futures-task\",\n+ \"pin-project-lite\",\n+ \"slab\",\n+]\n+\n+[[package]]\n+name = \"getrandom\"\n+version = \"0.2.17\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"wasi\",\n+]\n+\n+[[package]]\n+name = \"getrandom\"\n+version = \"0.4.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi\",\n+ \"wasip2\",\n+ \"wasip3\",\n+]\n+\n+[[package]]\n+name = \"h2\"\n+version = \"0.4.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733\"\n+dependencies = [\n+ \"atomic-waker\",\n+ \"bytes\",\n+ \"fnv\",\n+ \"futures-core\",\n+ \"futures-sink\",\n+ \"http\",\n+ \"indexmap\",\n+ \"slab\",\n+ \"tokio\",\n+ \"tokio-util\",\n+ \"tracing\",\n+]\n+\n+[[package]]\n+name = \"hashbrown\"\n+version = \"0.15.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1\"\n+dependencies = [\n+ \"foldhash\",\n+]\n+\n+[[package]]\n+name = \"hashbrown\"\n+version = \"0.17.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a\"\n+\n+[[package]]\n+name = \"heck\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n+\n+[[package]]\n+name = \"http\"\n+version = \"1.4.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0\"\n+dependencies = [\n+ \"bytes\",\n+ \"itoa\",\n+]\n+\n+[[package]]\n+name = \"http-body\"\n+version = \"1.0.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184\"\n+dependencies = [\n+ \"bytes\",\n+ \"http\",\n+]\n+\n+[[package]]\n+name = \"http-body-util\"\n+version = \"0.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a\"\n+dependencies = [\n+ \"bytes\",\n+ \"futures-core\",\n+ \"http\",\n+ \"http-body\",\n+ \"pin-project-lite\",\n+]\n+\n+[[package]]\n+name = \"httparse\"\n+version = \"1.10.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87\"\n+\n+[[package]]\n+name = \"httpdate\"\n+version = \"1.0.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9\"\n+\n+[[package]]\n+name = \"hyper\"\n+version = \"1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc\"\n+dependencies = [\n+ \"atomic-waker\",\n+ \"bytes\",\n+ \"futures-channel\",\n+ \"futures-core\",\n+ \"h2\",\n+ \"http\",\n+ \"http-body\",\n+ \"httparse\",\n+ \"httpdate\",\n+ \"itoa\",\n+ \"pin-project-lite\",\n+ \"smallvec\",\n+ \"tokio\",\n+ \"want\",\n+]\n+\n+[[package]]\n+name = \"hyper-rustls\"\n+version = \"0.27.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f\"\n+dependencies = [\n+ \"http\",\n+ \"hyper\",\n+ \"hyper-util\",\n+ \"rustls\",\n+ \"tokio\",\n+ \"tokio-rustls\",\n+ \"tower-service\",\n+]\n+\n+[[package]]\n+name = \"hyper-tls\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0\"\n+dependencies = [\n+ \"bytes\",\n+ \"http-body-util\",\n+ \"hyper\",\n+ \"hyper-util\",\n+ \"native-tls\",\n+ \"tokio\",\n+ \"tokio-native-tls\",\n+ \"tower-service\",\n+]\n+\n+[[package]]\n+name = \"hyper-util\"\n+version = \"0.1.20\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0\"\n+dependencies = [\n+ \"base64\",\n+ \"bytes\",\n+ \"futures-channel\",\n+ \"futures-util\",\n+ \"http\",\n+ \"http-body\",\n+ \"hyper\",\n+ \"ipnet\",\n+ \"libc\",\n+ \"percent-encoding\",\n+ \"pin-project-lite\",\n+ \"socket2\",\n+ \"system-configuration\",\n+ \"tokio\",\n+ \"tower-service\",\n+ \"tracing\",\n+ \"windows-registry\",\n+]\n+\n+[[package]]\n+name = \"icu_collections\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c\"\n+dependencies = [\n+ \"displaydoc\",\n+ \"potential_utf\",\n+ \"utf8_iter\",\n+ \"yoke\",\n+ \"zerofrom\",\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"icu_locale_core\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29\"\n+dependencies = [\n+ \"displaydoc\",\n+ \"litemap\",\n+ \"tinystr\",\n+ \"writeable\",\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"icu_normalizer\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4\"\n+dependencies = [\n+ \"icu_collections\",\n+ \"icu_normalizer_data\",\n+ \"icu_properties\",\n+ \"icu_provider\",\n+ \"smallvec\",\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"icu_normalizer_data\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38\"\n+\n+[[package]]\n+name = \"icu_properties\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de\"\n+dependencies = [\n+ \"icu_collections\",\n+ \"icu_locale_core\",\n+ \"icu_properties_data\",\n+ \"icu_provider\",\n+ \"zerotrie\",\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"icu_properties_data\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14\"\n+\n+[[package]]\n+name = \"icu_provider\"\n+version = \"2.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421\"\n+dependencies = [\n+ \"displaydoc\",\n+ \"icu_locale_core\",\n+ \"writeable\",\n+ \"yoke\",\n+ \"zerofrom\",\n+ \"zerotrie\",\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"id-arena\"\n+version = \"2.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954\"\n+\n+[[package]]\n+name = \"idna\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de\"\n+dependencies = [\n+ \"idna_adapter\",\n+ \"smallvec\",\n+ \"utf8_iter\",\n+]\n+\n+[[package]]\n+name = \"idna_adapter\"\n+version = \"1.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714\"\n+dependencies = [\n+ \"icu_normalizer\",\n+ \"icu_properties\",\n+]\n+\n+[[package]]\n+name = \"indexmap\"\n+version = \"2.14.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9\"\n+dependencies = [\n+ \"equivalent\",\n+ \"hashbrown 0.17.1\",\n+ \"serde\",\n+ \"serde_core\",\n+]\n+\n+[[package]]\n+name = \"ipnet\"\n+version = \"2.12.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2\"\n+\n+[[package]]\n+name = \"itoa\"\n+version = \"1.0.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682\"\n+\n+[[package]]\n+name = \"js-sys\"\n+version = \"0.3.99\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"futures-util\",\n+ \"once_cell\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"lazy_static\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n+\n+[[package]]\n+name = \"leb128fmt\"\n+version = \"0.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2\"\n+\n+[[package]]\n+name = \"libc\"\n+version = \"0.2.186\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\"\n+\n+[[package]]\n+name = \"linux-raw-sys\"\n+version = \"0.12.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53\"\n+\n+[[package]]\n+name = \"litemap\"\n+version = \"0.8.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0\"\n+\n+[[package]]\n+name = \"log\"\n+version = \"0.4.30\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5\"\n+\n+[[package]]\n+name = \"matchers\"\n+version = \"0.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9\"\n+dependencies = [\n+ \"regex-automata\",\n+]\n+\n+[[package]]\n+name = \"matchit\"\n+version = \"0.7.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94\"\n+\n+[[package]]\n+name = \"maud\"\n+version = \"0.26.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa\"\n+dependencies = [\n+ \"axum-core\",\n+ \"http\",\n+ \"itoa\",\n+ \"maud_macros\",\n+]\n+\n+[[package]]\n+name = \"maud_macros\"\n+version = \"0.26.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18\"\n+dependencies = [\n+ \"proc-macro-error\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"memchr\"\n+version = \"2.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8\"\n+\n+[[package]]\n+name = \"mime\"\n+version = \"0.3.17\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a\"\n+\n+[[package]]\n+name = \"mio\"\n+version = \"1.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1\"\n+dependencies = [\n+ \"libc\",\n+ \"wasi\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"multer\"\n+version = \"3.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b\"\n+dependencies = [\n+ \"bytes\",\n+ \"encoding_rs\",\n+ \"futures-util\",\n+ \"http\",\n+ \"httparse\",\n+ \"memchr\",\n+ \"mime\",\n+ \"spin\",\n+ \"version_check\",\n+]\n+\n+[[package]]\n+name = \"native-tls\"\n+version = \"0.2.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2\"\n+dependencies = [\n+ \"libc\",\n+ \"log\",\n+ \"openssl\",\n+ \"openssl-probe\",\n+ \"openssl-sys\",\n+ \"schannel\",\n+ \"security-framework\",\n+ \"security-framework-sys\",\n+ \"tempfile\",\n+]\n+\n+[[package]]\n+name = \"nu-ansi-term\"\n+version = \"0.50.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5\"\n+dependencies = [\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"num-conv\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\"\n+\n+[[package]]\n+name = \"once_cell\"\n+version = \"1.21.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50\"\n+\n+[[package]]\n+name = \"openssl\"\n+version = \"0.10.80\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\"\n+dependencies = [\n+ \"bitflags\",\n+ \"cfg-if\",\n+ \"foreign-types\",\n+ \"libc\",\n+ \"openssl-macros\",\n+ \"openssl-sys\",\n+]\n+\n+[[package]]\n+name = \"openssl-macros\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"openssl-probe\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe\"\n+\n+[[package]]\n+name = \"openssl-sys\"\n+version = \"0.9.116\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n+[[package]]\n+name = \"percent-encoding\"\n+version = \"2.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220\"\n+\n+[[package]]\n+name = \"pin-project-lite\"\n+version = \"0.2.17\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd\"\n+\n+[[package]]\n+name = \"pkg-config\"\n+version = \"0.3.33\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e\"\n+\n+[[package]]\n+name = \"potential_utf\"\n+version = \"0.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564\"\n+dependencies = [\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"powerfmt\"\n+version = \"0.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391\"\n+\n+[[package]]\n+name = \"prettyplease\"\n+version = \"0.2.37\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"proc-macro-error\"\n+version = \"1.0.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c\"\n+dependencies = [\n+ \"proc-macro-error-attr\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"version_check\",\n+]\n+\n+[[package]]\n+name = \"proc-macro-error-attr\"\n+version = \"1.0.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"version_check\",\n+]\n+\n+[[package]]\n+name = \"proc-macro2\"\n+version = \"1.0.106\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"quote\"\n+version = \"1.0.45\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924\"\n+dependencies = [\n+ \"proc-macro2\",\n+]\n+\n+[[package]]\n+name = \"r-efi\"\n+version = \"6.0.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf\"\n+\n+[[package]]\n+name = \"regex-automata\"\n+version = \"0.4.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-syntax\"\n+version = \"0.8.10\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a\"\n+\n+[[package]]\n+name = \"reqwest\"\n+version = \"0.12.28\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147\"\n+dependencies = [\n+ \"base64\",\n+ \"bytes\",\n+ \"encoding_rs\",\n+ \"futures-core\",\n+ \"h2\",\n+ \"http\",\n+ \"http-body\",\n+ \"http-body-util\",\n+ \"hyper\",\n+ \"hyper-rustls\",\n+ \"hyper-tls\",\n+ \"hyper-util\",\n+ \"js-sys\",\n+ \"log\",\n+ \"mime\",\n+ \"native-tls\",\n+ \"percent-encoding\",\n+ \"pin-project-lite\",\n+ \"rustls-pki-types\",\n+ \"serde\",\n+ \"serde_json\",\n+ \"serde_urlencoded\",\n+ \"sync_wrapper\",\n+ \"tokio\",\n+ \"tokio-native-tls\",\n+ \"tower\",\n+ \"tower-http 0.6.11\",\n+ \"tower-service\",\n+ \"url\",\n+ \"wasm-bindgen\",\n+ \"wasm-bindgen-futures\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"ring\"\n+version = \"0.17.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7\"\n+dependencies = [\n+ \"cc\",\n+ \"cfg-if\",\n+ \"getrandom 0.2.17\",\n+ \"libc\",\n+ \"untrusted\",\n+ \"windows-sys 0.52.0\",\n+]\n+\n+[[package]]\n+name = \"rustix\"\n+version = \"1.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190\"\n+dependencies = [\n+ \"bitflags\",\n+ \"errno\",\n+ \"libc\",\n+ \"linux-raw-sys\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"rustls\"\n+version = \"0.23.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b\"\n+dependencies = [\n+ \"once_cell\",\n+ \"rustls-pki-types\",\n+ \"rustls-webpki\",\n+ \"subtle\",\n+ \"zeroize\",\n+]\n+\n+[[package]]\n+name = \"rustls-pki-types\"\n+version = \"1.14.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9\"\n+dependencies = [\n+ \"zeroize\",\n+]\n+\n+[[package]]\n+name = \"rustls-webpki\"\n+version = \"0.103.13\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e\"\n+dependencies = [\n+ \"ring\",\n+ \"rustls-pki-types\",\n+ \"untrusted\",\n+]\n+\n+[[package]]\n+name = \"rustversion\"\n+version = \"1.0.22\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d\"\n+\n+[[package]]\n+name = \"ryu\"\n+version = \"1.0.23\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f\"\n+\n+[[package]]\n+name = \"schannel\"\n+version = \"0.1.29\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939\"\n+dependencies = [\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"security-framework\"\n+version = \"3.7.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d\"\n+dependencies = [\n+ \"bitflags\",\n+ \"core-foundation 0.10.1\",\n+ \"core-foundation-sys\",\n+ \"libc\",\n+ \"security-framework-sys\",\n+]\n+\n+[[package]]\n+name = \"security-framework-sys\"\n+version = \"2.17.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3\"\n+dependencies = [\n+ \"core-foundation-sys\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"semver\"\n+version = \"1.0.28\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd\"\n+\n+[[package]]\n+name = \"serde\"\n+version = \"1.0.228\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e\"\n+dependencies = [\n+ \"serde_core\",\n+ \"serde_derive\",\n+]\n+\n+[[package]]\n+name = \"serde_core\"\n+version = \"1.0.228\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad\"\n+dependencies = [\n+ \"serde_derive\",\n+]\n+\n+[[package]]\n+name = \"serde_derive\"\n+version = \"1.0.228\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"serde_json\"\n+version = \"1.0.150\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9\"\n+dependencies = [\n+ \"itoa\",\n+ \"memchr\",\n+ \"serde\",\n+ \"serde_core\",\n+ \"zmij\",\n+]\n+\n+[[package]]\n+name = \"serde_path_to_error\"\n+version = \"0.1.20\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457\"\n+dependencies = [\n+ \"itoa\",\n+ \"serde\",\n+ \"serde_core\",\n+]\n+\n+[[package]]\n+name = \"serde_urlencoded\"\n+version = \"0.7.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd\"\n+dependencies = [\n+ \"form_urlencoded\",\n+ \"itoa\",\n+ \"ryu\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"sharded-slab\"\n+version = \"0.1.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6\"\n+dependencies = [\n+ \"lazy_static\",\n+]\n+\n+[[package]]\n+name = \"shlex\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\"\n+\n+[[package]]\n+name = \"signal-hook-registry\"\n+version = \"1.4.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b\"\n+dependencies = [\n+ \"errno\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"slab\"\n+version = \"0.4.12\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5\"\n+\n+[[package]]\n+name = \"smallvec\"\n+version = \"1.15.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03\"\n+\n+[[package]]\n+name = \"socket2\"\n+version = \"0.6.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e\"\n+dependencies = [\n+ \"libc\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"sorter2-server\"\n+version = \"0.0.1\"\n+dependencies = [\n+ \"axum\",\n+ \"axum-extra\",\n+ \"maud\",\n+ \"reqwest\",\n+ \"serde\",\n+ \"serde_json\",\n+ \"tempfile\",\n+ \"thiserror\",\n+ \"tokio\",\n+ \"tower\",\n+ \"tower-http 0.5.2\",\n+ \"tracing\",\n+ \"tracing-subscriber\",\n+]\n+\n+[[package]]\n+name = \"spin\"\n+version = \"0.9.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67\"\n+\n+[[package]]\n+name = \"stable_deref_trait\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596\"\n+\n+[[package]]\n+name = \"subtle\"\n+version = \"2.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292\"\n+\n+[[package]]\n+name = \"syn\"\n+version = \"2.0.117\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"sync_wrapper\"\n+version = \"1.0.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263\"\n+dependencies = [\n+ \"futures-core\",\n+]\n+\n+[[package]]\n+name = \"synstructure\"\n+version = \"0.13.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"system-configuration\"\n+version = \"0.7.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b\"\n+dependencies = [\n+ \"bitflags\",\n+ \"core-foundation 0.9.4\",\n+ \"system-configuration-sys\",\n+]\n+\n+[[package]]\n+name = \"system-configuration-sys\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4\"\n+dependencies = [\n+ \"core-foundation-sys\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"tempfile\"\n+version = \"3.27.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd\"\n+dependencies = [\n+ \"fastrand\",\n+ \"getrandom 0.4.2\",\n+ \"once_cell\",\n+ \"rustix\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"thiserror\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52\"\n+dependencies = [\n+ \"thiserror-impl\",\n+]\n+\n+[[package]]\n+name = \"thiserror-impl\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"thread_local\"\n+version = \"1.1.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185\"\n+dependencies = [\n+ \"cfg-if\",\n+]\n+\n+[[package]]\n+name = \"time\"\n+version = \"0.3.47\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c\"\n+dependencies = [\n+ \"deranged\",\n+ \"itoa\",\n+ \"num-conv\",\n+ \"powerfmt\",\n+ \"serde_core\",\n+ \"time-core\",\n+ \"time-macros\",\n+]\n+\n+[[package]]\n+name = \"time-core\"\n+version = \"0.1.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca\"\n+\n+[[package]]\n+name = \"time-macros\"\n+version = \"0.2.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215\"\n+dependencies = [\n+ \"num-conv\",\n+ \"time-core\",\n+]\n+\n+[[package]]\n+name = \"tinystr\"\n+version = \"0.8.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d\"\n+dependencies = [\n+ \"displaydoc\",\n+ \"zerovec\",\n+]\n+\n+[[package]]\n+name = \"tokio\"\n+version = \"1.52.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe\"\n+dependencies = [\n+ \"bytes\",\n+ \"libc\",\n+ \"mio\",\n+ \"pin-project-lite\",\n+ \"signal-hook-registry\",\n+ \"socket2\",\n+ \"tokio-macros\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"tokio-macros\"\n+version = \"2.7.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"tokio-native-tls\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2\"\n+dependencies = [\n+ \"native-tls\",\n+ \"tokio\",\n+]\n+\n+[[package]]\n+name = \"tokio-rustls\"\n+version = \"0.26.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61\"\n+dependencies = [\n+ \"rustls\",\n+ \"tokio\",\n+]\n+\n+[[package]]\n+name = \"tokio-util\"\n+version = \"0.7.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098\"\n+dependencies = [\n+ \"bytes\",\n+ \"futures-core\",\n+ \"futures-sink\",\n+ \"pin-project-lite\",\n+ \"tokio\",\n+]\n+\n+[[package]]\n+name = \"tower\"\n+version = \"0.5.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4\"\n+dependencies = [\n+ \"futures-core\",\n+ \"futures-util\",\n+ \"pin-project-lite\",\n+ \"sync_wrapper\",\n+ \"tokio\",\n+ \"tower-layer\",\n+ \"tower-service\",\n+ \"tracing\",\n+]\n+\n+[[package]]\n+name = \"tower-http\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5\"\n+dependencies = [\n+ \"bitflags\",\n+ \"bytes\",\n+ \"http\",\n+ \"http-body\",\n+ \"http-body-util\",\n+ \"pin-project-lite\",\n+ \"tower-layer\",\n+ \"tower-service\",\n+ \"tracing\",\n+]\n+\n+[[package]]\n+name = \"tower-http\"\n+version = \"0.6.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\"\n+dependencies = [\n+ \"bitflags\",\n+ \"bytes\",\n+ \"futures-util\",\n+ \"http\",\n+ \"http-body\",\n+ \"pin-project-lite\",\n+ \"tower\",\n+ \"tower-layer\",\n+ \"tower-service\",\n+ \"url\",\n+]\n+\n+[[package]]\n+name = \"tower-layer\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e\"\n+\n+[[package]]\n+name = \"tower-service\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3\"\n+\n+[[package]]\n+name = \"tracing\"\n+version = \"0.1.44\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100\"\n+dependencies = [\n+ \"log\",\n+ \"pin-project-lite\",\n+ \"tracing-attributes\",\n+ \"tracing-core\",\n+]\n+\n+[[package]]\n+name = \"tracing-attributes\"\n+version = \"0.1.31\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"tracing-core\"\n+version = \"0.1.36\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a\"\n+dependencies = [\n+ \"once_cell\",\n+ \"valuable\",\n+]\n+\n+[[package]]\n+name = \"tracing-log\"\n+version = \"0.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3\"\n+dependencies = [\n+ \"log\",\n+ \"once_cell\",\n+ \"tracing-core\",\n+]\n+\n+[[package]]\n+name = \"tracing-subscriber\"\n+version = \"0.3.23\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319\"\n+dependencies = [\n+ \"matchers\",\n+ \"nu-ansi-term\",\n+ \"once_cell\",\n+ \"regex-automata\",\n+ \"sharded-slab\",\n+ \"smallvec\",\n+ \"thread_local\",\n+ \"tracing\",\n+ \"tracing-core\",\n+ \"tracing-log\",\n+]\n+\n+[[package]]\n+name = \"try-lock\"\n+version = \"0.2.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b\"\n+\n+[[package]]\n+name = \"unicode-ident\"\n+version = \"1.0.24\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75\"\n+\n+[[package]]\n+name = \"unicode-xid\"\n+version = \"0.2.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853\"\n+\n+[[package]]\n+name = \"untrusted\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1\"\n+\n+[[package]]\n+name = \"url\"\n+version = \"2.5.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed\"\n+dependencies = [\n+ \"form_urlencoded\",\n+ \"idna\",\n+ \"percent-encoding\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"utf8_iter\"\n+version = \"1.0.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be\"\n+\n+[[package]]\n+name = \"valuable\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65\"\n+\n+[[package]]\n+name = \"vcpkg\"\n+version = \"0.2.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\"\n+\n+[[package]]\n+name = \"version_check\"\n+version = \"0.9.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a\"\n+\n+[[package]]\n+name = \"want\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e\"\n+dependencies = [\n+ \"try-lock\",\n+]\n+\n+[[package]]\n+name = \"wasi\"\n+version = \"0.11.1+wasi-snapshot-preview1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b\"\n+\n+[[package]]\n+name = \"wasip2\"\n+version = \"1.0.3+wasi-0.2.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6\"\n+dependencies = [\n+ \"wit-bindgen 0.57.1\",\n+]\n+\n+[[package]]\n+name = \"wasip3\"\n+version = \"0.4.0+wasi-0.3.0-rc-2026-01-06\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5\"\n+dependencies = [\n+ \"wit-bindgen 0.51.0\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen\"\n+version = \"0.2.122\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"once_cell\",\n+ \"rustversion\",\n+ \"wasm-bindgen-macro\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-futures\"\n+version = \"0.4.72\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f\"\n+dependencies = [\n+ \"js-sys\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro\"\n+version = \"0.2.122\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6\"\n+dependencies = [\n+ \"quote\",\n+ \"wasm-bindgen-macro-support\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro-support\"\n+version = \"0.2.122\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e\"\n+dependencies = [\n+ \"bumpalo\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-shared\"\n+version = \"0.2.122\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"wasm-encoder\"\n+version = \"0.244.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319\"\n+dependencies = [\n+ \"leb128fmt\",\n+ \"wasmparser\",\n+]\n+\n+[[package]]\n+name = \"wasm-metadata\"\n+version = \"0.244.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909\"\n+dependencies = [\n+ \"anyhow\",\n+ \"indexmap\",\n+ \"wasm-encoder\",\n+ \"wasmparser\",\n+]\n+\n+[[package]]\n+name = \"wasmparser\"\n+version = \"0.244.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe\"\n+dependencies = [\n+ \"bitflags\",\n+ \"hashbrown 0.15.5\",\n+ \"indexmap\",\n+ \"semver\",\n+]\n+\n+[[package]]\n+name = \"web-sys\"\n+version = \"0.3.99\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436\"\n+dependencies = [\n+ \"js-sys\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"windows-link\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5\"\n+\n+[[package]]\n+name = \"windows-registry\"\n+version = \"0.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720\"\n+dependencies = [\n+ \"windows-link\",\n+ \"windows-result\",\n+ \"windows-strings\",\n+]\n+\n+[[package]]\n+name = \"windows-result\"\n+version = \"0.4.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5\"\n+dependencies = [\n+ \"windows-link\",\n+]\n+\n+[[package]]\n+name = \"windows-strings\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091\"\n+dependencies = [\n+ \"windows-link\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.52.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d\"\n+dependencies = [\n+ \"windows-targets\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.61.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc\"\n+dependencies = [\n+ \"windows-link\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm\",\n+ \"windows_aarch64_msvc\",\n+ \"windows_i686_gnu\",\n+ \"windows_i686_gnullvm\",\n+ \"windows_i686_msvc\",\n+ \"windows_x86_64_gnu\",\n+ \"windows_x86_64_gnullvm\",\n+ \"windows_x86_64_msvc\",\n+]\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec\"\n+\n+[[package]]\n+name = \"wit-bindgen\"\n+version = \"0.51.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5\"\n+dependencies = [\n+ \"wit-bindgen-rust-macro\",\n+]\n+\n+[[package]]\n+name = \"wit-bindgen\"\n+version = \"0.57.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e\"\n+\n+[[package]]\n+name = \"wit-bindgen-core\"\n+version = \"0.51.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc\"\n+dependencies = [\n+ \"anyhow\",\n+ \"heck\",\n+ \"wit-parser\",\n+]\n+\n+[[package]]\n+name = \"wit-bindgen-rust\"\n+version = \"0.51.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21\"\n+dependencies = [\n+ \"anyhow\",\n+ \"heck\",\n+ \"indexmap\",\n+ \"prettyplease\",\n+ \"syn\",\n+ \"wasm-metadata\",\n+ \"wit-bindgen-core\",\n+ \"wit-component\",\n+]\n+\n+[[package]]\n+name = \"wit-bindgen-rust-macro\"\n+version = \"0.51.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a\"\n+dependencies = [\n+ \"anyhow\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wit-bindgen-core\",\n+ \"wit-bindgen-rust\",\n+]\n+\n+[[package]]\n+name = \"wit-component\"\n+version = \"0.244.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2\"\n+dependencies = [\n+ \"anyhow\",\n+ \"bitflags\",\n+ \"indexmap\",\n+ \"log\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"wasm-encoder\",\n+ \"wasm-metadata\",\n+ \"wasmparser\",\n+ \"wit-parser\",\n+]\n+\n+[[package]]\n+name = \"wit-parser\"\n+version = \"0.244.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736\"\n+dependencies = [\n+ \"anyhow\",\n+ \"id-arena\",\n+ \"indexmap\",\n+ \"log\",\n+ \"semver\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"unicode-xid\",\n+ \"wasmparser\",\n+]\n+\n+[[package]]\n+name = \"writeable\"\n+version = \"0.6.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4\"\n+\n+[[package]]\n+name = \"yoke\"\n+version = \"0.8.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca\"\n+dependencies = [\n+ \"stable_deref_trait\",\n+ \"yoke-derive\",\n+ \"zerofrom\",\n+]\n+\n+[[package]]\n+name = \"yoke-derive\"\n+version = \"0.8.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"synstructure\",\n+]\n+\n+[[package]]\n+name = \"zerofrom\"\n+version = \"0.1.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272\"\n+dependencies = [\n+ \"zerofrom-derive\",\n+]\n+\n+[[package]]\n+name = \"zerofrom-derive\"\n+version = \"0.1.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"synstructure\",\n+]\n+\n+[[package]]\n+name = \"zeroize\"\n+version = \"1.8.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0\"\n+\n+[[package]]\n+name = \"zerotrie\"\n+version = \"0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf\"\n+dependencies = [\n+ \"displaydoc\",\n+ \"yoke\",\n+ \"zerofrom\",\n+]\n+\n+[[package]]\n+name = \"zerovec\"\n+version = \"0.11.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239\"\n+dependencies = [\n+ \"yoke\",\n+ \"zerofrom\",\n+ \"zerovec-derive\",\n+]\n+\n+[[package]]\n+name = \"zerovec-derive\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"zmij\"\n+version = \"1.0.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa\"\ndiff --git a/Cargo.toml b/Cargo.toml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..156820ec5151b709a254dafc177da10488fee566\n--- /dev/null\n+++ b/Cargo.toml\n@@ -0,0 +1,3 @@\n+[workspace]\n+members = [\"server\"]\n+resolver = \"2\"\ndiff --git a/Dockerfile b/Dockerfile\nindex 9cb07c60cb0da063f747cfbf1b3b876ecb8ba03e..38e4d595f2366b6a6e9405f1e12373b70fac79a1 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -1,5 +1,4 @@\n-# time 0.3.47+ requires Rust 1.88 (edition 2024)\n-FROM rust:1.88-slim as builder\n+FROM rust:1.88-slim AS builder\n \n WORKDIR /build\n \n@@ -7,9 +6,8 @@ RUN apt-get update && \\\n apt-get install -y pkg-config libssl-dev && \\\n rm -rf /var/lib/apt/lists/*\n \n-# Copy source and build. (Keep it simple to avoid remote build cache oddities.)\n COPY . .\n-RUN cargo build --release --package slugsocial-server\n+RUN cargo build --release --package sorter2-server\n \n FROM debian:bookworm-slim\n \n@@ -19,16 +17,14 @@ RUN apt-get update && \\\n \n WORKDIR /app\n \n-COPY --from=builder /build/target/release/slugsocial-server /app/slugsocial-server\n+COPY --from=builder /build/target/release/sorter2-server /app/sorter2-server\n \n-# Create data directory for persistent volume\n RUN mkdir -p /data\n \n-ENV SLUG_DATA_DIR=/data\n-ENV SLUG_EVENT_LOG=/data/events.jsonl\n+ENV SORTER2_DATA_DIR=/data\n+ENV SORTER2_EVENT_LOG=/data/events.jsonl\n ENV PORT=8080\n \n EXPOSE 8080\n \n-CMD [\"/app/slugsocial-server\"]\n-\n+CMD [\"/app/sorter2-server\"]\ndiff --git a/bundle.js b/bundle.js\ndeleted file mode 100644\nindex 8d316f9a57cc7c379fe4bd8e42e092c7eac5d265..0000000000000000000000000000000000000000\n--- a/bundle.js\n+++ /dev/null\n@@ -1,52 +0,0 @@\n-/**\n- * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here.\n- */\n-(function () {\n- function evalJs(js) {\n- if (js && String(js).trim()) {\n- eval(js);\n- }\n- }\n-\n- // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie)\n-\n- function initSlugUi() {\n- // POST forms → eval response (except theme + full-navigation forms)\n- document.addEventListener('submit', async function (e) {\n- var f = e.target;\n- if (!f || f.tagName !== 'FORM') return;\n- if ((f.method || 'get').toLowerCase() !== 'post') return;\n- if (f.id === 'slug-theme-form') return;\n- if (f.getAttribute('data-navigate') === 'full') return;\n- e.preventDefault();\n- var resp = await fetch(f.action, {\n- method: 'POST',\n- body: new URLSearchParams(new FormData(f)),\n- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n- credentials: 'same-origin',\n- });\n- evalJs(await resp.text());\n- });\n-\n- // SSE: server-pushed JS\n- function connectSSE() {\n- var ssePath = window.location.pathname + window.location.search;\n- var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath));\n- es.onmessage = function (e) {\n- evalJs(e.data);\n- };\n- es.onerror = function () {\n- es.close();\n- setTimeout(connectSSE, 3000);\n- };\n- }\n- connectSSE();\n- }\n-\n- if (document.readyState === 'loading') {\n- document.addEventListener('DOMContentLoaded', initSlugUi);\n- } else {\n- initSlugUi();\n- }\n-})();\n-\ndiff --git a/clj-test.sh b/clj-test.sh\ndeleted file mode 100755\nindex b62a49b98ed60a65a46807b7ad80fa3142f14952..0000000000000000000000000000000000000000\n--- a/clj-test.sh\n+++ /dev/null\n@@ -1,5 +0,0 @@\n-#!/usr/bin/env bash\n-set -euo pipefail\n-cd \"$(dirname \"$0\")/..\"\n-mkdir -p target\n-exec clojure -M:kaocha\ndiff --git a/deps.edn b/deps.edn\nindex 0bf892d44f491cb2313e01ae8a942c3097c52948..e3812b9e0a4f21fe4cbe11fa96688e18a4a62d23 100644\n--- a/deps.edn\n+++ b/deps.edn\n@@ -1,8 +1,5 @@\n {:paths [\".\" \"test\"]\n- :deps {cheshire/cheshire {:mvn/version \"5.13.0\"}\n- http-kit/http-kit {:mvn/version \"2.8.0\"}\n- babashka/fs {:mvn/version \"0.5.32\"}\n- babashka/process {:mvn/version \"0.6.25\"}\n+ :deps {babashka/process {:mvn/version \"0.6.25\"}\n com.blockether/spel {:mvn/version \"0.7.11\"}}\n :aliases\n {:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version \"1.91.1392\"}\ndiff --git a/event_log.rs b/event_log.rs\ndeleted file mode 100644\nindex eaae0d495e43a45d6590603892265a62cc92906e..0000000000000000000000000000000000000000\n--- a/event_log.rs\n+++ /dev/null\n@@ -1,83 +0,0 @@\n-use std::path::{Path, PathBuf};\n-\n-use tokio::{\n- fs::{self, OpenOptions},\n- io::{AsyncBufReadExt, AsyncWriteExt, BufReader},\n-};\n-\n-use crate::events::Event;\n-\n-#[derive(Debug, thiserror::Error)]\n-pub enum EventLogError {\n- #[error(\"io error: {0}\")]\n- Io(#[from] std::io::Error),\n- #[error(\"json error: {0}\")]\n- Json(#[from] serde_json::Error),\n-}\n-\n-#[derive(Debug, Clone)]\n-pub struct EventLog {\n- path: PathBuf,\n-}\n-\n-impl EventLog {\n- pub fn new(path: impl Into) -> Self {\n- Self { path: path.into() }\n- }\n-\n- pub fn path(&self) -> &Path {\n- &self.path\n- }\n-\n- pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {\n- if let Some(parent) = self.path.parent() {\n- fs::create_dir_all(parent).await?;\n- }\n- Ok(())\n- }\n-\n- pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {\n- self.ensure_parent_dir().await?;\n- let mut f: tokio::fs::File = OpenOptions::new()\n- .create(true)\n- .append(true)\n- .open(&self.path)\n- .await?;\n-\n- let mut line = serde_json::to_string(event)?;\n- line.push('\\n');\n- f.write_all(line.as_bytes()).await?;\n- f.flush().await?;\n- Ok(())\n- }\n-\n- /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`.\n- pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n- if !fs::try_exists(&self.path).await? {\n- return Ok((vec![], vec![]));\n- }\n-\n- let f = fs::File::open(&self.path).await?;\n- let mut reader = BufReader::new(f).lines();\n-\n- let mut events = Vec::new();\n- let mut bad_lines = Vec::new();\n-\n- let mut line_no: usize = 0;\n- while let Some(line) = reader.next_line().await? {\n- line_no += 1;\n- let trimmed = line.trim();\n- if trimmed.is_empty() {\n- continue;\n- }\n- match serde_json::from_str::(trimmed) {\n- Ok(ev) => events.push(ev),\n- Err(_) => bad_lines.push((line_no, line)),\n- }\n- }\n-\n- Ok((events, bad_lines))\n- }\n-}\n-\n-\ndiff --git a/fly.toml b/fly.toml\nindex bbb9345e527452db1d87a549213645c195eae5fc..f0c6a39f643c204987debc177234d95a8ef44b65 100644\n--- a/fly.toml\n+++ b/fly.toml\n@@ -1,14 +1,18 @@\n-app = \"slugsocial\"\n+app = \"sorter2\"\n primary_region = \"iad\"\n \n [build]\n dockerfile = \"Dockerfile\"\n \n [env]\n- SLUG_DATA_DIR = \"/data\"\n- SLUG_EVENT_LOG = \"/data/events.jsonl\"\n+ SORTER2_DATA_DIR = \"/data\"\n+ SORTER2_EVENT_LOG = \"/data/events.jsonl\"\n PORT = \"8080\"\n \n+[vm]\n+ memory = \"256mb\"\n+ cpus = 1\n+\n [[services]]\n internal_port = 8080\n protocol = \"tcp\"\n@@ -24,19 +28,17 @@ primary_region = \"iad\"\n \n [services.concurrency]\n type = \"connections\"\n- hard_limit = 1000\n- soft_limit = 500\n+ hard_limit = 250\n+ soft_limit = 100\n \n [[services.http_checks]]\n- interval = \"10s\"\n+ interval = \"15s\"\n timeout = \"2s\"\n- grace_period = \"5s\"\n+ grace_period = \"10s\"\n method = \"GET\"\n path = \"/healthz\"\n protocol = \"http\"\n- tls_skip_verify = false\n \n [[mounts]]\n- source = \"slugsocial_data\"\n+ source = \"sorter2_data\"\n destination = \"/data\"\n-\ndiff --git a/forms.rs b/forms.rs\ndeleted file mode 100644\nindex d509fd889fde3fed2662ce0a39006b7a51ae762a..0000000000000000000000000000000000000000\n--- a/forms.rs\n+++ /dev/null\n@@ -1,145 +0,0 @@\n-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs\n-//! Plan2-style JSON templates with `{\"$form\": \"field_name\"}` holes, filled from\n-//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**\n-//! deserializing into a typed struct.\n-//!\n-//! # Wire format\n-//!\n-//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty\n-//! printing, strings escaped per JSON rules (`\\\"`, `\\n`, etc.). Embed that string\n-//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not\n-//! bespoke encodings.\n-//!\n-//! # Power vs flat hidden fields\n-//!\n-//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),\n-//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,\n-//! arrays, and optional fields without inventing a new naming scheme each time.\n-//!\n-//! # Security\n-//!\n-//! Substitution runs **before** `serde` into your command type. It does not fix\n-//! authorization: if the client can replace the hidden `__rpc__` value, they can\n-//! change the command shape unless you validate (signed blob, server-side session\n-//! context, or treat the blob as hints only). Same threat model as any hidden field.\n-\n-use serde::Serialize;\n-use serde_json::Value;\n-use std::collections::HashMap;\n-\n-/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.\n-pub fn template_json_compact(v: &T) -> serde_json::Result {\n- serde_json::to_string(v)\n-}\n-\n-/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n-/// string for `key` (empty if missing). Other keys are unchanged.\n-pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n- match val {\n- Value::Object(map) => {\n- if map.len() == 1 {\n- if let Some(Value::String(field_name)) = map.get(\"$form\") {\n- let submitted = form_data\n- .get(field_name.as_str())\n- .map(|s| s.as_str())\n- .unwrap_or(\"\");\n- *val = Value::String(submitted.to_string());\n- return;\n- }\n- }\n- for v in map.values_mut() {\n- substitute_form_vars(v, form_data);\n- }\n- }\n- Value::Array(arr) => {\n- for v in arr.iter_mut() {\n- substitute_form_vars(v, form_data);\n- }\n- }\n- _ => {}\n- }\n-}\n-\n-/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.\n-pub fn fill_template_from_form(\n- template_json: &str,\n- form_data: &HashMap,\n-) -> Result {\n- let mut v: Value = serde_json::from_str(template_json)?;\n- substitute_form_vars(&mut v, form_data);\n- Ok(v)\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n- use serde::Deserialize;\n-\n- #[derive(Debug, Deserialize, PartialEq, Eq)]\n- struct Demo {\n- room: String,\n- thread_tag: String,\n- nested: Nested,\n- }\n-\n- #[derive(Debug, Deserialize, PartialEq, Eq)]\n- struct Nested {\n- text: String,\n- }\n-\n- #[test]\n- fn holes_become_strings() {\n- let json = r#\"{\n- \"room\": \"public\",\n- \"thread_tag\": {\"$form\": \"tag\"},\n- \"nested\": {\"text\": {\"$form\": \"body\"}}\n- }\"#;\n- let mut form = HashMap::new();\n- form.insert(\"tag\".into(), \"foo\".into());\n- form.insert(\"body\".into(), \"hello\\nworld\".into());\n-\n- let v = fill_template_from_form(json, &form).unwrap();\n- let d: Demo = serde_json::from_value(v).unwrap();\n- assert_eq!(\n- d,\n- Demo {\n- room: \"public\".into(),\n- thread_tag: \"foo\".into(),\n- nested: Nested {\n- text: \"hello\\nworld\".into(),\n- },\n- }\n- );\n- }\n-\n- #[test]\n- fn missing_form_key_is_empty_string() {\n- let json = r#\"{\"x\": {\"$form\": \"nope\"}}\"#;\n- let mut form = HashMap::new();\n- form.insert(\"other\".into(), \"y\".into());\n- let v = fill_template_from_form(json, &form).unwrap();\n- assert_eq!(v[\"x\"], \"\");\n- }\n-\n- #[test]\n- fn array_of_holes() {\n- let json = r#\"{\"items\": [{\"$form\": \"a\"}, {\"$form\": \"b\"}]}\"#;\n- let mut form = HashMap::new();\n- form.insert(\"a\".into(), \"1\".into());\n- form.insert(\"b\".into(), \"2\".into());\n- let v = fill_template_from_form(json, &form).unwrap();\n- assert_eq!(v[\"items\"], serde_json::json!([\"1\", \"2\"]));\n- }\n-\n- #[test]\n- fn template_json_compact_escapes_and_single_line() {\n- let s = template_json_compact(&serde_json::json!({\n- \"x\": \"quote\\\"and\\nnewline\"\n- }))\n- .unwrap();\n- assert!(!s.contains('\\n'));\n- assert!(s.contains(\"\\\\\\\"\") || s.contains(\"\\\\n\"));\n- }\n-}\n-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ \n-\ndiff --git a/gameifying.tdsl b/gameifying.tdsl\ndeleted file mode 100644\nindex 93d2a61d94fcba4370c82d9612e6bb0f0318cc15..0000000000000000000000000000000000000000\n--- a/gameifying.tdsl\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. \n-or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive.\ndiff --git a/ideas/gameifying.tdsl b/ideas/gameifying.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15\n--- /dev/null\n+++ b/ideas/gameifying.tdsl\n@@ -0,0 +1,2 @@\n+consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. \n+or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive.\ndiff --git a/ideas/pagerank_streaming.tdsl b/ideas/pagerank_streaming.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a\n--- /dev/null\n+++ b/ideas/pagerank_streaming.tdsl\n@@ -0,0 +1,11 @@\n+eventually i want to have ranking histories.\n+like \"visionary\" and \"fraud\" waxing and waning in a uplot graph over time for #elon-musk. \n+there are too many query combinations to precomupte the ranking histories \n+(arbitrary user filters, and tag overlap combinations maybe), \n+so we're just going to have to calculate them all on demand. \n+computers are fast, its okay. for each vote, we need to calculate rank centrality again. \n+i was thinking, for n votes we calculate the rank centrality, \n+and get node weights. we then output that data to the client (over websocket, or testable barrier). \n+then we calculate n+1 votes, _but we keep the node weights in memory_ \n+so the rank centrality process converges faster. \n+this also has the side effect of making the ranking stream in satisfyingly as you load the page.\ndiff --git a/ideas/parser.tdsl b/ideas/parser.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..03ba9563ea8c73b7411cd1aa42dda9c7c313cd39\n--- /dev/null\n+++ b/ideas/parser.tdsl\n@@ -0,0 +1,32 @@\n+The parser is a series of edges/transitions, for example:\n+r -> reddit.com/ \n+where -> means that all of\n+ r->reddit.com/\n+ re->reddit.com/\n+ red->reddit.com/\n+ redd->reddit.com/\n+ reddi->reddit.com/\n+ reddit->reddit.com/\n+ reddit.->reddit.com/\n+ reddit.c->reddit.com/\n+ reddit.co->reddit.com/\n+ reddit.com->reddt.com/\n+ are defined as autocomplete suggestions\n+\n+re -> reddit.com/\n+reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/\n+reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n+reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/\n+reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n+\n+\n+h->https:// (show supported domains)\n+\n+// the edges are composable such that this is possible, while reusing the above reddit code.\n+https://r->https://reddit.com/\n+https://w->https://www.\n+https://www.r->https://www.reddit.com/\n+\n+\n+// and i also want to have commands after you press space, like\n+reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% \ndiff --git a/ideas/seed.tdsl b/ideas/seed.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0b707179f09781c53d6526cffeb9158579698ad7\n--- /dev/null\n+++ b/ideas/seed.tdsl\n@@ -0,0 +1,23 @@\n+reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social?\n+jsonl based. Same stack as slug\n+fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval\n+Viewcounters for every site\n+Login with Reddit oauth?\n+Streaks? Streak gates? Progressive revelation?\n+One pair of the day\n+Use attributes again, for assholery. To distinguish from reddits single dimension\n+r/sorter_south_park_characters \n+Is a one shot category that only has the characters.\n+subs starting with sorter have special priority in sorter index.\n+\n+layer above reddit?\n+\n+\n+use websocket based bar i had built in afterlife... \n+\n+\n+claude {{\n+What I'd push on hardest: what's the artifact that gets shared? Letterboxd works because every movie has a beautiful URL, every user has a beautiful profile, every list is screenshot-worthy. AITA-but-ranked needs the same. \"This person ranks 97th percentile of asshole, beating 4,891 other Reddit posters this month\" — presented well, that's a tweet, that's a screenshot, that's how the next thousand users find you. Build the share artifact early. Streaks and progressive revelation are for retention; the share-out is for acquisition; you need both but the acquisition piece is what makes streaks worth designing.\n+}}\n+\n+have index be sorted by viewcount.\n\\ No newline at end of file\ndiff --git a/legacy/bundle.js b/legacy/bundle.js\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265\n--- /dev/null\n+++ b/legacy/bundle.js\n@@ -0,0 +1,52 @@\n+/**\n+ * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here.\n+ */\n+(function () {\n+ function evalJs(js) {\n+ if (js && String(js).trim()) {\n+ eval(js);\n+ }\n+ }\n+\n+ // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie)\n+\n+ function initSlugUi() {\n+ // POST forms → eval response (except theme + full-navigation forms)\n+ document.addEventListener('submit', async function (e) {\n+ var f = e.target;\n+ if (!f || f.tagName !== 'FORM') return;\n+ if ((f.method || 'get').toLowerCase() !== 'post') return;\n+ if (f.id === 'slug-theme-form') return;\n+ if (f.getAttribute('data-navigate') === 'full') return;\n+ e.preventDefault();\n+ var resp = await fetch(f.action, {\n+ method: 'POST',\n+ body: new URLSearchParams(new FormData(f)),\n+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n+ credentials: 'same-origin',\n+ });\n+ evalJs(await resp.text());\n+ });\n+\n+ // SSE: server-pushed JS\n+ function connectSSE() {\n+ var ssePath = window.location.pathname + window.location.search;\n+ var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath));\n+ es.onmessage = function (e) {\n+ evalJs(e.data);\n+ };\n+ es.onerror = function () {\n+ es.close();\n+ setTimeout(connectSSE, 3000);\n+ };\n+ }\n+ connectSSE();\n+ }\n+\n+ if (document.readyState === 'loading') {\n+ document.addEventListener('DOMContentLoaded', initSlugUi);\n+ } else {\n+ initSlugUi();\n+ }\n+})();\n+\ndiff --git a/legacy/clj-test.sh b/legacy/clj-test.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952\n--- /dev/null\n+++ b/legacy/clj-test.sh\n@@ -0,0 +1,5 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+cd \"$(dirname \"$0\")/..\"\n+mkdir -p target\n+exec clojure -M:kaocha\ndiff --git a/legacy/event_log.rs b/legacy/event_log.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..eaae0d495e43a45d6590603892265a62cc92906e\n--- /dev/null\n+++ b/legacy/event_log.rs\n@@ -0,0 +1,83 @@\n+use std::path::{Path, PathBuf};\n+\n+use tokio::{\n+ fs::{self, OpenOptions},\n+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},\n+};\n+\n+use crate::events::Event;\n+\n+#[derive(Debug, thiserror::Error)]\n+pub enum EventLogError {\n+ #[error(\"io error: {0}\")]\n+ Io(#[from] std::io::Error),\n+ #[error(\"json error: {0}\")]\n+ Json(#[from] serde_json::Error),\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct EventLog {\n+ path: PathBuf,\n+}\n+\n+impl EventLog {\n+ pub fn new(path: impl Into) -> Self {\n+ Self { path: path.into() }\n+ }\n+\n+ pub fn path(&self) -> &Path {\n+ &self.path\n+ }\n+\n+ pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {\n+ if let Some(parent) = self.path.parent() {\n+ fs::create_dir_all(parent).await?;\n+ }\n+ Ok(())\n+ }\n+\n+ pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {\n+ self.ensure_parent_dir().await?;\n+ let mut f: tokio::fs::File = OpenOptions::new()\n+ .create(true)\n+ .append(true)\n+ .open(&self.path)\n+ .await?;\n+\n+ let mut line = serde_json::to_string(event)?;\n+ line.push('\\n');\n+ f.write_all(line.as_bytes()).await?;\n+ f.flush().await?;\n+ Ok(())\n+ }\n+\n+ /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`.\n+ pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ if !fs::try_exists(&self.path).await? {\n+ return Ok((vec![], vec![]));\n+ }\n+\n+ let f = fs::File::open(&self.path).await?;\n+ let mut reader = BufReader::new(f).lines();\n+\n+ let mut events = Vec::new();\n+ let mut bad_lines = Vec::new();\n+\n+ let mut line_no: usize = 0;\n+ while let Some(line) = reader.next_line().await? {\n+ line_no += 1;\n+ let trimmed = line.trim();\n+ if trimmed.is_empty() {\n+ continue;\n+ }\n+ match serde_json::from_str::(trimmed) {\n+ Ok(ev) => events.push(ev),\n+ Err(_) => bad_lines.push((line_no, line)),\n+ }\n+ }\n+\n+ Ok((events, bad_lines))\n+ }\n+}\n+\n+\ndiff --git a/legacy/forms.rs b/legacy/forms.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a\n--- /dev/null\n+++ b/legacy/forms.rs\n@@ -0,0 +1,145 @@\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs\n+//! Plan2-style JSON templates with `{\"$form\": \"field_name\"}` holes, filled from\n+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**\n+//! deserializing into a typed struct.\n+//!\n+//! # Wire format\n+//!\n+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty\n+//! printing, strings escaped per JSON rules (`\\\"`, `\\n`, etc.). Embed that string\n+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not\n+//! bespoke encodings.\n+//!\n+//! # Power vs flat hidden fields\n+//!\n+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),\n+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,\n+//! arrays, and optional fields without inventing a new naming scheme each time.\n+//!\n+//! # Security\n+//!\n+//! Substitution runs **before** `serde` into your command type. It does not fix\n+//! authorization: if the client can replace the hidden `__rpc__` value, they can\n+//! change the command shape unless you validate (signed blob, server-side session\n+//! context, or treat the blob as hints only). Same threat model as any hidden field.\n+\n+use serde::Serialize;\n+use serde_json::Value;\n+use std::collections::HashMap;\n+\n+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.\n+pub fn template_json_compact(v: &T) -> serde_json::Result {\n+ serde_json::to_string(v)\n+}\n+\n+/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n+/// string for `key` (empty if missing). Other keys are unchanged.\n+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n+ match val {\n+ Value::Object(map) => {\n+ if map.len() == 1 {\n+ if let Some(Value::String(field_name)) = map.get(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = Value::String(submitted.to_string());\n+ return;\n+ }\n+ }\n+ for v in map.values_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ Value::Array(arr) => {\n+ for v in arr.iter_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ _ => {}\n+ }\n+}\n+\n+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.\n+pub fn fill_template_from_form(\n+ template_json: &str,\n+ form_data: &HashMap,\n+) -> Result {\n+ let mut v: Value = serde_json::from_str(template_json)?;\n+ substitute_form_vars(&mut v, form_data);\n+ Ok(v)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde::Deserialize;\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Demo {\n+ room: String,\n+ thread_tag: String,\n+ nested: Nested,\n+ }\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Nested {\n+ text: String,\n+ }\n+\n+ #[test]\n+ fn holes_become_strings() {\n+ let json = r#\"{\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"tag\"},\n+ \"nested\": {\"text\": {\"$form\": \"body\"}}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"tag\".into(), \"foo\".into());\n+ form.insert(\"body\".into(), \"hello\\nworld\".into());\n+\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ let d: Demo = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ d,\n+ Demo {\n+ room: \"public\".into(),\n+ thread_tag: \"foo\".into(),\n+ nested: Nested {\n+ text: \"hello\\nworld\".into(),\n+ },\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn missing_form_key_is_empty_string() {\n+ let json = r#\"{\"x\": {\"$form\": \"nope\"}}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"other\".into(), \"y\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"x\"], \"\");\n+ }\n+\n+ #[test]\n+ fn array_of_holes() {\n+ let json = r#\"{\"items\": [{\"$form\": \"a\"}, {\"$form\": \"b\"}]}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"a\".into(), \"1\".into());\n+ form.insert(\"b\".into(), \"2\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"items\"], serde_json::json!([\"1\", \"2\"]));\n+ }\n+\n+ #[test]\n+ fn template_json_compact_escapes_and_single_line() {\n+ let s = template_json_compact(&serde_json::json!({\n+ \"x\": \"quote\\\"and\\nnewline\"\n+ }))\n+ .unwrap();\n+ assert!(!s.contains('\\n'));\n+ assert!(s.contains(\"\\\\\\\"\") || s.contains(\"\\\\n\"));\n+ }\n+}\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ \n+\ndiff --git a/legacy/mod.rs b/legacy/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..b1dfe895515a1ba94304c52ac4ea82ff6bb84c16\n--- /dev/null\n+++ b/legacy/mod.rs\n@@ -0,0 +1,929 @@\n+use axum::{\n+ body::Body,\n+ extract::Path,\n+ http::{header, HeaderValue, StatusCode, Uri},\n+ response::{IntoResponse, Response},\n+ Form,\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::{html, Markup, DOCTYPE};\n+use serde::Deserialize;\n+use std::collections::{HashMap, HashSet};\n+\n+mod auth;\n+mod breadcrumb_path;\n+mod editor;\n+mod forum;\n+mod garden;\n+pub mod routing;\n+mod search;\n+pub mod ui_action;\n+use breadcrumb_path::{ExternalOntologyPath, OntologyPath};\n+\n+pub use auth::{\n+ auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,\n+ choose_username_page,\n+};\n+pub use editor::{editor_check, editor_page};\n+pub use forum::{\n+ home, room_page, room_thread_post_view, room_thread_view, thread_feed_html,\n+ thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav,\n+};\n+\n+pub use forum::user_profile_page;\n+pub(crate) use forum::{\n+ fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,\n+ thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n+ user_can_post_room, user_can_view_room,\n+};\n+pub(crate) use garden::{\n+ encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,\n+ GARDEN_PIN_COOKIE,\n+};\n+pub use garden::{\n+ external_garden_index, external_ontology_path, garden_index, ontology_path,\n+ room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,\n+ room_vote_compare_page, vote_compare_page,\n+};\n+pub use routing::RouteContext;\n+pub use search::{search_page, search_results_fragment};\n+pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};\n+\n+/// Public profile URL path for a stored username (no `@`).\n+pub(crate) fn profile_href(username: &str) -> String {\n+ format!(\"/u/{username}\")\n+}\n+\n+/// Cookie name for UI theme (must match document.cookie migration in [`layout`]).\n+pub const SLUG_THEME_COOKIE: &str = \"slug-theme\";\n+\n+/// Normalize a requested theme id to a known stylesheet key.\n+pub fn normalize_theme(raw: &str) -> &'static str {\n+ match raw {\n+ \"retro\" => \"retro\",\n+ \"retro_craft\" => \"retro_craft\",\n+ _ => \"default\",\n+ }\n+}\n+\n+/// Resolved theme for rendering and cookie re-issue.\n+pub fn theme_from_jar(jar: &CookieJar) -> &'static str {\n+ jar.get(SLUG_THEME_COOKIE)\n+ .map(|c| normalize_theme(c.value()))\n+ .unwrap_or(\"default\")\n+}\n+\n+/// `Path` + optional `?query` for round-tripping after `POST /theme`.\n+pub fn theme_next_from_uri(uri: &Uri) -> String {\n+ uri.path_and_query()\n+ .map(|pq| pq.as_str().to_string())\n+ .filter(|s| !s.is_empty())\n+ .unwrap_or_else(|| \"/\".to_string())\n+}\n+\n+/// `Set-Cookie` for a validated theme (ASCII cookie value).\n+pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {\n+ let t = normalize_theme(theme);\n+ let s = format!(\"{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000\");\n+ HeaderValue::from_str(&s).expect(\"theme cookie must be ASCII\")\n+}\n+\n+/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme.\n+pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option {\n+ let c = jar.get(SLUG_THEME_COOKIE)?;\n+ Some(theme_cookie_header_value(c.value()))\n+}\n+\n+fn sanitize_theme_next(next: Option<&str>) -> String {\n+ let s = next.unwrap_or(\"/\").trim();\n+ if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n+ s.to_string()\n+ } else {\n+ \"/\".to_string()\n+ }\n+}\n+\n+#[derive(Debug, Deserialize)]\n+pub struct ThemeForm {\n+ theme: String,\n+ next: Option,\n+}\n+\n+/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch).\n+pub async fn post_theme(Form(form): Form) -> impl IntoResponse {\n+ let theme = normalize_theme(&form.theme);\n+ let next = sanitize_theme_next(form.next.as_deref());\n+ let loc =\n+ HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n+ Response::builder()\n+ .status(StatusCode::SEE_OTHER)\n+ .header(header::LOCATION, loc)\n+ .header(header::SET_COOKIE, theme_cookie_header_value(theme))\n+ .body(Body::empty())\n+ .expect(\"theme redirect response\")\n+}\n+\n+// Embed CSS and shared UI script at compile time\n+const THEME_DEFAULT_CSS: &str = include_str!(\"../../static/theme_default.css\");\n+const THEME_RETRO_CSS: &str = include_str!(\"../../static/theme_retro.css\");\n+const THEME_RETRO_CRAFT_CSS: &str = include_str!(\"../../static/theme_retro_craft.css\");\n+const SLUG_UI_JS: &str = include_str!(\"../../static/slug_ui.js\");\n+\n+pub async fn serve_static(Path(filename): Path) -> impl IntoResponse {\n+ if filename == \"slug_ui.js\" {\n+ return Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n+ .body(SLUG_UI_JS.to_string())\n+ .unwrap()\n+ .into_response();\n+ }\n+\n+ let theme = filename\n+ .strip_prefix(\"theme_\")\n+ .and_then(|s| s.strip_suffix(\".css\"));\n+\n+ let css = match theme {\n+ Some(\"default\") => THEME_DEFAULT_CSS,\n+ Some(\"retro\") => THEME_RETRO_CSS,\n+ Some(\"retro_craft\") => THEME_RETRO_CRAFT_CSS,\n+ _ => return (StatusCode::NOT_FOUND, \"static file not found\").into_response(),\n+ };\n+\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/css; charset=utf-8\")\n+ .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n+ .body(css.to_string())\n+ .unwrap()\n+ .into_response()\n+}\n+\n+pub(crate) fn js_string_literal(s: &str) -> String {\n+ serde_json::to_string(s).expect(\"javascript string escaping\")\n+}\n+\n+/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures.\n+pub(crate) fn ui_js_warn(msg: &str) -> Response {\n+ let js = format!(\"console.warn({});\", js_string_literal(msg));\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(Body::from(js))\n+ .unwrap()\n+}\n+\n+pub(crate) struct JsBuilder {\n+ snippets: Vec,\n+}\n+\n+pub(crate) struct JsQueryBuilder {\n+ builder: JsBuilder,\n+ expr: String,\n+}\n+\n+impl JsBuilder {\n+ pub(crate) fn new() -> Self {\n+ Self {\n+ snippets: Vec::new(),\n+ }\n+ }\n+\n+ pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self {\n+ self.morph_expr(\n+ &format!(\"document.querySelector({})\", js_string_literal(selector)),\n+ markup,\n+ None,\n+ )\n+ }\n+\n+ /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`.\n+ pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self {\n+ self.qs(selector).morph_inner(markup)\n+ }\n+\n+ pub(crate) fn morph_expr(\n+ mut self,\n+ expr: &str,\n+ markup: Markup,\n+ morph_style: Option<&str>,\n+ ) -> Self {\n+ let html = js_string_literal(&markup.into_string());\n+ let opts = morph_style\n+ .map(|style| format!(\", {{morphStyle: {}}}\", js_string_literal(style)))\n+ .unwrap_or_default();\n+ self.snippets.push(format!(\n+ \"var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}\",\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder {\n+ JsQueryBuilder {\n+ builder: self,\n+ expr: format!(\"document.querySelector({})\", js_string_literal(selector)),\n+ }\n+ }\n+\n+ pub(crate) fn id(self, id: &str) -> JsQueryBuilder {\n+ self.qs(&format!(\"#{id}\"))\n+ }\n+\n+ pub(crate) fn if_current_path_matches(\n+ mut self,\n+ path: &str,\n+ f: impl FnOnce(JsBuilder) -> JsBuilder,\n+ ) -> Self {\n+ let inner = f(JsBuilder::new()).build();\n+ self.snippets.push(format!(\n+ \"var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}\",\n+ path = js_string_literal(path),\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn if_current_path_not_matches(\n+ mut self,\n+ path: &str,\n+ f: impl FnOnce(JsBuilder) -> JsBuilder,\n+ ) -> Self {\n+ let inner = f(JsBuilder::new()).build();\n+ self.snippets.push(format!(\n+ \"var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}\",\n+ path = js_string_literal(path),\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn redirect(mut self, to: &str) -> Self {\n+ self.snippets\n+ .push(format!(\"window.location = {};\", js_string_literal(to)));\n+ self\n+ }\n+\n+ pub(crate) fn clipboard_write_text_and_label_btn(\n+ mut self,\n+ text: &str,\n+ btn_id: &str,\n+ copied_label: &str,\n+ ) -> Self {\n+ self.snippets.push(format!(\n+ \"navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});\",\n+ text = js_string_literal(text),\n+ btn_id = js_string_literal(btn_id),\n+ label = js_string_literal(copied_label),\n+ ));\n+ self\n+ }\n+\n+ /// Focus first matching element (e.g. after morphing open a compose form).\n+ pub(crate) fn focus_selector(mut self, selector: &str) -> Self {\n+ self.snippets.push(format!(\n+ \"var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}\",\n+ js_string_literal(selector),\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn build(self) -> String {\n+ self.snippets.join(\" \")\n+ }\n+\n+ pub(crate) fn into_response(self) -> Response {\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(Body::from(self.build()))\n+ .unwrap()\n+ }\n+}\n+\n+impl JsQueryBuilder {\n+ pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder {\n+ let html = js_string_literal(&markup.into_string());\n+ self.builder.snippets.push(format!(\n+ \"var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}\",\n+ expr = self.expr,\n+ ));\n+ self.builder\n+ }\n+\n+ pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder {\n+ let html = js_string_literal(&markup.into_string());\n+ self.builder.snippets.push(format!(\n+ \"var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}\",\n+ expr = self.expr,\n+ ));\n+ self.builder\n+ }\n+\n+ pub(crate) fn reset(mut self) -> JsBuilder {\n+ self.builder.snippets.push(format!(\n+ \"var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}\",\n+ expr = self.expr,\n+ ));\n+ self.builder\n+ }\n+}\n+\n+#[allow(clippy::too_many_arguments)]\n+pub(super) fn layout(\n+ title: &str,\n+ view: &str,\n+ body: Markup,\n+ views: Option,\n+ theme: &str,\n+ theme_next: &str,\n+ garden_room_wire: Option<&str>,\n+ garden_path_prefix: Option<&str>,\n+) -> Markup {\n+ layout_embed_controls(\n+ title,\n+ view,\n+ body,\n+ views,\n+ theme,\n+ theme_next,\n+ garden_room_wire,\n+ garden_path_prefix,\n+ true,\n+ )\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+ views: Option,\n+ theme: &str,\n+ theme_next: &str,\n+) -> Markup {\n+ layout_embed_controls(\n+ title, view, body, views, theme, theme_next, None, None, false,\n+ )\n+}\n+\n+#[allow(clippy::too_many_arguments)]\n+fn layout_embed_controls(\n+ title: &str,\n+ view: &str,\n+ body: Markup,\n+ views: Option,\n+ theme: &str,\n+ theme_next: &str,\n+ garden_room_wire: Option<&str>,\n+ garden_path_prefix: Option<&str>,\n+ show_controls: bool,\n+) -> Markup {\n+ let theme = normalize_theme(theme);\n+ let css_href = format!(\"/static/theme_{theme}.css\");\n+ html! {\n+ (DOCTYPE)\n+ html {\n+ head {\n+ meta charset=\"utf-8\";\n+ meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n+ title { (title) }\n+ link rel=\"stylesheet\" href=(css_href) id=\"theme-stylesheet\";\n+ script src=\"https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js\" {}\n+ }\n+ body class=(view)\n+ data-garden-room=(garden_room_wire.unwrap_or(\"\"))\n+ data-garden-prefix=(garden_path_prefix.unwrap_or(\"\")) {\n+ @if let Some(n) = views {\n+ span class=\"view-meta muted\" { (n) \" views\" }\n+ }\n+ div id=\"errors\" {}\n+ (body)\n+ @if show_controls {\n+ div id=\"controls\" {\n+ a href=\"https://github.com/sortersocial/slug\" id=\"src-link\" { \"src\" }\n+ div id=\"spread-control\" {\n+ span { \"spread\" }\n+ input type=\"range\" id=\"spread-slider\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\";\n+ }\n+ @if let (Some(gr), Some(ref gpx)) = (garden_room_wire, garden_path_prefix) {\n+ @if !gr.is_empty() {\n+ div id=\"slug-pin-hud\" class=\"slug-pin-hud\" data-garden-prefix=(gpx) {}\n+ }\n+ }\n+ a id=\"search-btn\" href=\"/search\" { \"search\" }\n+ form id=\"slug-theme-form\" method=\"post\" action=\"/theme\" {\n+ input type=\"hidden\" name=\"next\" value=(theme_next);\n+ select id=\"theme-select\" name=\"theme\" onchange=\"this.form.submit()\" aria-label=\"Theme\" {\n+ @for (val, label) in [(\"default\", \"default\"), (\"retro\", \"retro\"), (\"retro_craft\", \"craft\")] {\n+ @if theme == val {\n+ option value=(val) selected { (label) }\n+ } @else {\n+ option value=(val) { (label) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ script src=\"/static/slug_ui.js\" {}\n+ }\n+ }\n+ }\n+}\n+\n+pub(super) fn now_ms() -> i64 {\n+ let t = std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap_or_default();\n+ t.as_millis() as i64\n+}\n+\n+pub(super) fn ratio_pct(left: i32, right: i32) -> f64 {\n+ let l = (left.max(0)) as f64;\n+ let r = (right.max(0)) as f64;\n+ let denom = l + r;\n+ if denom <= 0.0 {\n+ return 50.0;\n+ }\n+ (l / denom) * 100.0\n+}\n+\n+/// Render a single breadcrumb segment with `/` separator.\n+pub(super) fn bc_segment(label: &str, href: &str, is_current: bool) -> Markup {\n+ html! {\n+ span class=\"bc-sep\" { \" / \" }\n+ @if is_current {\n+ a href=(href) class=\"bc-current\" { (label) }\n+ } @else {\n+ a href=(href) { (label) }\n+ }\n+ }\n+}\n+\n+/// Breadcrumb for external ontology `/-/https://…`\n+fn bc_path_external(path: &ExternalOntologyPath) -> Markup {\n+ html! {\n+ a href=\"/\" { \"slug.social\" }\n+ (bc_segment(\"-\", \"/-\", path.is_root()))\n+ @for (i, id) in path.breadcrumb_chain().iter().enumerate() {\n+ @let disp = id.display_path();\n+ @let tail = disp.strip_prefix(\"-/\").unwrap_or(disp.as_str());\n+ @let href = format!(\"/-/{}\", tail);\n+ @let is_last = i + 1 == path.breadcrumb_chain().len();\n+ (bc_segment(id.last_segment(), &href, is_last))\n+ }\n+ }\n+}\n+\n+/// Breadcrumb for any ontology path segment, e.g. \"parables\" or \"parables/counting-the-cost\".\n+fn bc_path(path: &OntologyPath) -> Markup {\n+ html! {\n+ a href=(path.slug_root_href()) { \"slug.social\" }\n+ (bc_segment(\"~\", \"/~\", path.is_root()))\n+ @for (i, seg) in path.segments().iter().enumerate() {\n+ @let href = format!(\"/~/{}\", path.segments()[..=i].join(\"/\"));\n+ @let is_last = i == path.segments().len() - 1;\n+ (bc_segment(seg, &href, is_last))\n+ }\n+ }\n+}\n+\n+/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post.\n+/// Root link toggles to `/~` only at thread-root (`/`).\n+pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup {\n+ let root_href = if thread_tag.is_some() { \"/\" } else { \"/~\" };\n+ let root_is_current = thread_tag.is_none();\n+ html! {\n+ @if root_is_current {\n+ a href=(root_href) class=\"bc-current\" { \"slug.social\" }\n+ } @else {\n+ a href=(root_href) { \"slug.social\" }\n+ }\n+ @if let Some(tag) = thread_tag {\n+ @if let Some(idx) = focused_post {\n+ (bc_segment(&format!(\"#{tag}\"), &format!(\"/t/{tag}\"), false))\n+ (bc_segment(&format!(\"post #{idx}\"), &format!(\"/t/{tag}/{idx}\"), true))\n+ } @else {\n+ (bc_segment(&format!(\"#{tag}\"), &format!(\"/t/{tag}\"), true))\n+ }\n+ }\n+ }\n+}\n+\n+/// Short display label for a stored agent id (`uuid:rig:model`, no `@`).\n+pub(super) fn actor_label(agent_naked: &str) -> String {\n+ let a = agent_naked.trim();\n+ let parts: Vec<&str> = a.split(':').collect();\n+ if parts.len() >= 3 {\n+ let rig = parts[1].trim();\n+ let model = parts[2].trim();\n+ // X actors: show @handle instead of uuid hash.\n+ if rig == \"x.com\" {\n+ return format!(\"@{model}\");\n+ }\n+ let uuid = parts[0].trim();\n+ let uuid8 = uuid.chars().take(8).collect::();\n+ if !uuid8.is_empty() && !rig.is_empty() && !model.is_empty() {\n+ return format!(\"{uuid8}:{rig}:{model}\");\n+ }\n+ }\n+ a.to_string()\n+}\n+\n+/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present.\n+pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String {\n+ match delegate {\n+ Some(d) => format!(\"@@{}\", actor_label(d)),\n+ None => format!(\"@{principal}\"),\n+ }\n+}\n+\n+/// Escape HTML special chars for safe injection.\n+fn escape_html(s: &str) -> String {\n+ let mut out = String::with_capacity(s.len());\n+ for c in s.chars() {\n+ match c {\n+ '&' => out.push_str(\"&\"),\n+ '<' => out.push_str(\"<\"),\n+ '>' => out.push_str(\">\"),\n+ '\"' => out.push_str(\""\"),\n+ _ => out.push(c),\n+ }\n+ }\n+ out\n+}\n+\n+/// Max characters from an item body placed in a `title` tooltip (native hover).\n+const ITEM_LINK_TITLE_MAX_CHARS: usize = 500;\n+\n+fn collapse_whitespace_for_title(s: &str) -> String {\n+ let mut out = String::with_capacity(s.len().min(ITEM_LINK_TITLE_MAX_CHARS + 8));\n+ let mut last_was_space = true;\n+ for c in s.chars() {\n+ if c.is_whitespace() {\n+ if !last_was_space {\n+ out.push(' ');\n+ last_was_space = true;\n+ }\n+ } else {\n+ out.push(c);\n+ last_was_space = false;\n+ }\n+ }\n+ out.trim().to_string()\n+}\n+\n+fn item_body_title_snippet(body: &str) -> Option {\n+ let s = collapse_whitespace_for_title(body);\n+ if s.is_empty() {\n+ return None;\n+ }\n+ let truncated: String = s.chars().take(ITEM_LINK_TITLE_MAX_CHARS).collect();\n+ let ellipsis = if s.chars().count() > ITEM_LINK_TITLE_MAX_CHARS {\n+ \"…\"\n+ } else {\n+ \"\"\n+ };\n+ Some(format!(\"{truncated}{ellipsis}\"))\n+}\n+\n+fn garden_href_for_item_ref(\n+ raw_ref: &str,\n+ garden_prefix: &str,\n+) -> Option<(crate::path_types::ItemId, String)> {\n+ let key = slug_types::canonicalize_item(raw_ref);\n+ let id = crate::path_types::ItemId::parse(&key)?;\n+ let href = if let Some(tail) = id.tilde_tail() {\n+ if tail.is_empty() {\n+ garden_prefix.trim_end_matches('/').to_string()\n+ } else {\n+ format!(\"{}/{}\", garden_prefix.trim_end_matches('/'), tail)\n+ }\n+ } else if id.as_str().starts_with(\"https://\") || id.as_str().starts_with(\"http://\") {\n+ let display = id.display_path();\n+ let rest = display.strip_prefix(\"-/\").unwrap_or(display.as_str());\n+ let ext_prefix = format!(\"{}-\", garden_prefix.trim_end_matches('~'));\n+ format!(\"{ext_prefix}/{rest}\")\n+ } else {\n+ return None;\n+ };\n+ Some((id, href))\n+}\n+\n+fn push_item_ref_anchor(\n+ out: &mut String,\n+ raw_ref: &str,\n+ garden_prefix: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> bool {\n+ let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else {\n+ return false;\n+ };\n+ out.push_str(r#\"');\n+ out.push_str(&escape_html(raw_ref));\n+ out.push_str(\"\");\n+ true\n+}\n+\n+/// Replace item refs in raw prose with clickable garden links.\n+///\n+/// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated\n+/// body preview for native browser tooltips (forum posts, item pages).\n+pub(super) fn linkify_slugs_with_prefix(\n+ raw: &str,\n+ garden_prefix: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> String {\n+ let mut out = String::with_capacity(raw.len() + 64);\n+ for token in crate::dsl::tokenize_prose_item_refs(raw) {\n+ match token {\n+ crate::dsl::ProseToken::Text(text) => out.push_str(&escape_html(&text)),\n+ crate::dsl::ProseToken::ItemRef(raw_ref) => {\n+ if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) {\n+ out.push_str(&escape_html(&raw_ref));\n+ }\n+ }\n+ }\n+ }\n+ out\n+}\n+\n+#[derive(Clone)]\n+struct EmbedFrame {\n+ src: String,\n+ title: String,\n+ provider_class: &'static str,\n+}\n+\n+fn clean_media_id(s: &str) -> String {\n+ s.chars()\n+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')\n+ .collect()\n+}\n+\n+fn query_param(url: &str, name: &str) -> Option {\n+ let q = url.split_once('?')?.1;\n+ for pair in q.split('&') {\n+ let (k, v) = pair.split_once('=').unwrap_or((pair, \"\"));\n+ if k == name {\n+ return Some(v.to_string());\n+ }\n+ }\n+ None\n+}\n+\n+fn spotify_embed_src(url: &str) -> Option {\n+ let rest = url\n+ .strip_prefix(\"https://\")\n+ .or_else(|| url.strip_prefix(\"http://\"))?;\n+ let (host, tail) = rest.split_once('/').unwrap_or((rest, \"\"));\n+ let host = host.to_lowercase();\n+ if !(host == \"open.spotify.com\" || host == \"www.open.spotify.com\") {\n+ return None;\n+ }\n+ let path = tail\n+ .split('#')\n+ .next()\n+ .unwrap_or(tail)\n+ .split('?')\n+ .next()\n+ .unwrap_or(tail);\n+ let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();\n+ if segs.first().is_some_and(|s| s.starts_with(\"intl-\")) {\n+ segs.remove(0);\n+ }\n+ if segs.len() < 2 {\n+ return None;\n+ }\n+ let kind = segs[0];\n+ let id = clean_media_id(segs[1]);\n+ if id.is_empty() {\n+ return None;\n+ }\n+ let allowed = matches!(kind, \"track\" | \"album\" | \"playlist\" | \"episode\" | \"show\");\n+ if !allowed {\n+ return None;\n+ }\n+ Some(EmbedFrame {\n+ src: format!(\"https://open.spotify.com/embed/{kind}/{id}\"),\n+ title: format!(\"Spotify {kind}\"),\n+ provider_class: \"embed-spotify\",\n+ })\n+}\n+\n+fn youtube_embed_src(url: &str) -> Option {\n+ let rest = url\n+ .strip_prefix(\"https://\")\n+ .or_else(|| url.strip_prefix(\"http://\"))?;\n+ let (host, tail) = rest.split_once('/').unwrap_or((rest, \"\"));\n+ let host = host.to_lowercase();\n+\n+ let video_id = if host == \"youtu.be\" || host == \"www.youtu.be\" {\n+ clean_media_id(\n+ tail.split(['?', '#'])\n+ .next()\n+ .unwrap_or(tail)\n+ .trim_matches('/'),\n+ )\n+ } else if matches!(\n+ host.as_str(),\n+ \"youtube.com\" | \"www.youtube.com\" | \"m.youtube.com\" | \"music.youtube.com\"\n+ ) {\n+ let path = format!(\"/{}\", tail.split('#').next().unwrap_or(tail));\n+ if path.starts_with(\"/watch\") {\n+ clean_media_id(&query_param(url, \"v\")?)\n+ } else if let Some(id) = path.strip_prefix(\"/shorts/\") {\n+ clean_media_id(id.split(['?', '/']).next().unwrap_or(id))\n+ } else if let Some(id) = path.strip_prefix(\"/embed/\") {\n+ clean_media_id(id.split(['?', '/']).next().unwrap_or(id))\n+ } else {\n+ String::new()\n+ }\n+ } else {\n+ String::new()\n+ };\n+\n+ if video_id.is_empty() {\n+ return None;\n+ }\n+ Some(EmbedFrame {\n+ src: format!(\"https://www.youtube.com/embed/{video_id}\"),\n+ title: \"YouTube video\".to_string(),\n+ provider_class: \"embed-youtube\",\n+ })\n+}\n+\n+fn extract_embed_frames(raw: &str) -> Vec {\n+ let mut out = Vec::new();\n+ let mut seen = HashSet::new();\n+ for token in raw.split_whitespace() {\n+ let token = token\n+ .trim_matches(|c: char| c == '(' || c == '[' || c == '{' || c == '\"' || c == '\\'')\n+ .trim_end_matches(|c: char| \".,;:!?)]}\\\"'\".contains(c));\n+ if !(token.starts_with(\"https://\") || token.starts_with(\"http://\")) {\n+ continue;\n+ }\n+ let embed = spotify_embed_src(token).or_else(|| youtube_embed_src(token));\n+ if let Some(frame) = embed {\n+ if seen.insert(frame.src.clone()) {\n+ out.push(frame);\n+ }\n+ }\n+ }\n+ out\n+}\n+\n+pub(super) fn render_linkified_with_embeds_in_scope(\n+ raw: &str,\n+ garden_prefix: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> Markup {\n+ let embeds = extract_embed_frames(raw);\n+ html! {\n+ pre { (maud::PreEscaped(linkify_slugs_with_prefix(raw, garden_prefix, item_bodies))) }\n+ @if !embeds.is_empty() {\n+ div class=\"rich-embeds\" {\n+ @for e in embeds {\n+ div class=(format!(\"rich-embed {}\", e.provider_class)) {\n+ iframe\n+ src=(e.src)\n+ title=(e.title)\n+ loading=\"lazy\"\n+ allow=\"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share\"\n+ referrerpolicy=\"strict-origin-when-cross-origin\"\n+ allowfullscreen {}\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+/// Item page / thread body: resolver-specific rich HTML, else linkified `
` + media embeds.\n+pub(super) fn render_item_body_in_scope(\n+    raw: &str,\n+    garden_prefix: &str,\n+    item_bodies: Option<&HashMap>,\n+) -> Markup {\n+    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {\n+        return html! {\n+            div class=\"item-body-rich\" { (m) }\n+        };\n+    }\n+    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)\n+}\n+\n+/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.\n+fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {\n+    assert!(\n+        !s.contains('\\\\') && !s.contains('\\'') && !s.contains('\\n') && !s.contains('\\r'),\n+        \"cli_panel cmd must not contain `\\\\`, `'`, or newlines (got {s:?})\"\n+    );\n+}\n+\n+/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.\n+pub(super) fn cli_panel>(cmds: &[I]) -> Markup {\n+    if cmds.is_empty() {\n+        return html! {};\n+    }\n+    for cmd in cmds {\n+        assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());\n+    }\n+    html! {\n+        div class=\"cli-panel\" {\n+            span class=\"cli-panel-label muted\" { \"cli\" }\n+            div class=\"cli-panel-cmds\" {\n+                @for cmd in cmds {\n+                    @let s = cmd.as_ref();\n+                    button type=\"button\" class=\"cli-panel-row\" title=\"Copy command\" onclick=(format!(\n+                        r#\"navigator.clipboard.writeText('{}');\"#,\n+                        s\n+                    )) {\n+                        code class=\"cli-panel-cmd\" { (s) }\n+                    }\n+                }\n+            }\n+        }\n+    }\n+}\n+\n+/// Age bucket for recency coloring of thread entries.\n+pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {\n+    let age_ms = now_ms.saturating_sub(ts_ms);\n+    let age_secs = age_ms / 1000;\n+    if age_secs < 3600 {\n+        \"age-fresh\" // < 1 hour\n+    } else if age_secs < 86400 {\n+        \"age-recent\" // < 1 day\n+    } else if age_secs < 86400 * 7 {\n+        \"age-week\" // < 1 week\n+    } else {\n+        \"age-old\" // >= 1 week\n+    }\n+}\n+\n+#[cfg(test)]\n+mod linkify_title_tests {\n+    use super::*;\n+    use crate::path_types::ItemId;\n+    use std::collections::HashMap;\n+\n+    #[test]\n+    fn tilde_link_gets_title_from_item_bodies() {\n+        let mut bodies = HashMap::new();\n+        let key = ItemId::parse(&slug_types::canonicalize_item(\"~/foo/bar\")).unwrap();\n+        bodies.insert(key, \"Hello  world\\nline\".to_string());\n+        let html = linkify_slugs_with_prefix(\"see ~/foo/bar ok\", \"/r/x/~\", Some(&bodies));\n+        assert!(html.contains(\"title=\\\"Hello world line\\\"\"));\n+        assert!(html.contains(\"href=\\\"/r/x/~/foo/bar\\\"\"));\n+    }\n+\n+    #[test]\n+    fn raw_url_links_to_public_external_garden_page() {\n+        let html = linkify_slugs_with_prefix(\"see https://example.com/z.\", \"/~\", None);\n+        assert!(html.contains(\n+            r#\"https://example.com/z.\"#\n+        ));\n+    }\n+\n+    #[test]\n+    fn dash_ref_links_to_room_external_garden_page_with_title() {\n+        let mut bodies = HashMap::new();\n+        let key = ItemId::parse(&slug_types::canonicalize_item(\"-/example.com/z\")).unwrap();\n+        bodies.insert(key, \"External body\\npreview\".to_string());\n+        let html =\n+            linkify_slugs_with_prefix(\"see -/example.com/z\", \"/r/9ab12cdroom/~\", Some(&bodies));\n+        assert!(html.contains(r#\"href=\"/r/9ab12cdroom/-/https://example.com/z\"\"#));\n+        assert!(html.contains(r#\"title=\"External body preview\"\"#));\n+    }\n+\n+    #[test]\n+    fn code_fence_urls_are_not_linkified() {\n+        let html = linkify_slugs_with_prefix(\n+            \"```json\\n{\\\"url\\\":\\\"https://example.com/z\\\"}\\n```\\nthen https://example.com/a\",\n+            \"/~\",\n+            None,\n+        );\n+        assert!(!html.contains(r#\"href=\"/-/https://example.com/z\"\"#));\n+        assert!(html.contains(r#\"href=\"/-/https://example.com/a\"\"#));\n+    }\n+\n+    #[test]\n+    fn no_title_when_body_missing_or_empty() {\n+        let html = linkify_slugs_with_prefix(\"x ~/a/b y\", \"/~\", Some(&HashMap::new()));\n+        assert!(!html.contains(\" title=\"));\n+    }\n+}\ndiff --git a/legacy/parser.rs b/legacy/parser.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349\n--- /dev/null\n+++ b/legacy/parser.rs\n@@ -0,0 +1,1808 @@\n+use std::collections::HashMap;\n+use std::rc::Rc;\n+use std::cell::RefCell;\n+use crate::ui::action::UIAction;\n+use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};\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+    }\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\n+type Handler = Box) -> UIAction>;\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\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+        let root_node = Rc::new(RefCell::new(Node {\n+            id: \"root\",\n+            edges: Vec::new(),\n+            handler: None,\n+        }));\n+        nodes.insert(\"root\", root_node);\n+        \n+        GraphBuilder {\n+            nodes,\n+            current_node: Some(\"root\"),\n+            root: \"root\",\n+        }\n+    }\n+    \n+    /// Select a node to add edges to\n+    pub fn at(mut self, node_id: NodeId) -> Self {\n+        // Create node if it doesn't exist\n+        if !self.nodes.contains_key(node_id) {\n+            let node = Rc::new(RefCell::new(Node {\n+                id: node_id,\n+                edges: Vec::new(),\n+                handler: None,\n+            }));\n+            self.nodes.insert(node_id, node);\n+        }\n+        self.current_node = Some(node_id);\n+        self\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(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {\n+        let current = self.current_node.expect(\"No current node selected\");\n+        \n+        // Create target node if it doesn't exist\n+        if !self.nodes.contains_key(target) {\n+            let node = Rc::new(RefCell::new(Node {\n+                id: target,\n+                edges: Vec::new(),\n+                handler: None,\n+            }));\n+            self.nodes.insert(target, node);\n+        }\n+        \n+        // Add edge to current node\n+        if let Some(node) = self.nodes.get(current) {\n+            node.borrow_mut().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(self, handler: F) -> Self \n+    where \n+        F: Fn(&str, &str, &HashMap) -> UIAction + 'static\n+    {\n+        let current = self.current_node.expect(\"No current node selected\");\n+        if let Some(node) = self.nodes.get(current) {\n+            node.borrow_mut().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+}\n+\n+// --- Parser Implementation ---\n+\n+impl Graph {\n+    pub fn parse(&self, input: &str) -> UIAction {\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) -> UIAction {\n+        let node = self.nodes.get(state.current_node_id)\n+            .expect(\"Node not found in graph\");\n+        let node_ref = node.borrow();\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_ref.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_ref, state);\n+        }\n+        \n+        let remaining = &state.input[state.cursor..];\n+        \n+        // Try to match each edge\n+        for edge in &node_ref.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 UIAction::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, UIAction::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_ref, state)\n+    }\n+    \n+    fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {\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 UIAction::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 UIAction::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+        UIAction::error(\n+            \"InvalidPath\".to_string(),\n+            format!(\"'{}' doesn't match any known pattern\", state.original_query)\n+        )\n+    }\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+}\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+    }\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+                UIAction::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+                UIAction::multiple(vec![\n+                    UIAction::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+                    UIAction::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+                UIAction::multiple(vec![\n+                    UIAction::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+                    UIAction::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+                UIAction::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+                UIAction::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+                UIAction::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+                UIAction::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+                UIAction::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+                UIAction::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+                UIAction::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+                UIAction::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+/// Parse a query string and return a UI action\n+pub fn parse_reddit_url(query: &str) -> UIAction {\n+    let graph = build_reddit_graph();\n+    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: &UIAction) -> bool {\n+            match (self, action) {\n+                (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {\n+                    data.suggestion.as_ref()\n+                        .map(|s| s.completion == *completion)\n+                        .unwrap_or(false)\n+                }\n+                (ExpectedAction::ScrollingSuggestions { completions }, UIAction::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 }, UIAction::ShowStaticGuide { title, .. }) => {\n+                    title.contains(title_contains)\n+                }\n+                (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n+                 UIAction::RenderEntityView { ns, pk }) => {\n+                    ns == \"reddit.subreddit\" && pk == subreddit\n+                }\n+                (ExpectedAction::RenderSubredditComments { subreddit }, \n+                 UIAction::RenderEntityView { ns, pk }) => {\n+                    ns == \"reddit.subreddit\" && pk == subreddit\n+                }\n+                (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {\n+                    ns == \"reddit.user\" && pk == username\n+                }\n+                (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n+                 UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n+                    s == subreddit && p == prefix\n+                }\n+                (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {\n+                    data.error_type == *error_type\n+                }\n+                (ExpectedAction::Multiple { expected_actions }, UIAction::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, UIAction::ShowMultiple { .. }) => true,\n+                (ExpectedAction::DbSuggestions { partial, prefix }, \n+                 UIAction::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, UIAction)> {\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 UIAction::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 UIAction::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, UIAction::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, UIAction::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, UIAction::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, UIAction::ShowMultiple { .. })\n+                }),\n+                Box::new(|text, action| {\n+                    // \"r/rust\" should resolve the subreddit\n+                    text == \"r/rust\" && matches!(action, UIAction::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+                    UIAction::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+                    UIAction::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, UIAction::RenderEntityView { ns, pk } \n+                        if ns == \"reddit.subreddit\" && pk == \"programming\")\n+                }),\n+            ],\n+        );\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, UIAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n+                }),\n+            ],\n+        );\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, UIAction::ShowMultiple { .. })\n+                }),\n+                Box::new(|text, action| {\n+                    text == \"r/technology\" && \n+                    matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n+                }),\n+            ],\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, UIAction::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, UIAction::ShowMultiple { .. })\n+                }),\n+            ],\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, UIAction::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, UIAction::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, UIAction::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, UIAction::ShowSuggestions(_))\n+                }),\n+                Box::new(|text, action| {\n+                    // \"https://reddit.com/\" should show guide\n+                    text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+                }),\n+                Box::new(|text, action| {\n+                    // \"https://reddit.com/r/\" should show subreddit selection\n+                    text == \"https://reddit.com/r/\" && matches!(action, UIAction::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, UIAction::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, UIAction::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, UIAction::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+            UIAction::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+    }\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+            UIAction::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+                UIAction::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+                UIAction::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+            UIAction::ShowMultiple { actions } => {\n+                let has_guide = actions.iter()\n+                    .any(|a| matches!(a, UIAction::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+            UIAction::ShowMultiple { actions } => {\n+                let has_scrolling_suggestions = actions.iter()\n+                    .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));\n+                let has_db_suggestions = actions.iter()\n+                    .any(|a| matches!(a, UIAction::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+            UIAction::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+            UIAction::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+    }\n+}\ndiff --git a/legacy/ranking.rs b/legacy/ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..34241cf6bf13117b3ad3262761eb7f7b48b54d80\n--- /dev/null\n+++ b/legacy/ranking.rs\n@@ -0,0 +1,393 @@\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  cat server/src/ranking.rs \n+use std::collections::{HashMap, HashSet};\n+\n+use crate::path_types::ItemId;\n+use crate::reducer::GroupState;\n+\n+#[derive(Debug, Clone)]\n+pub struct RankedItem {\n+    pub item: ItemId,\n+    pub score: f64,\n+}\n+\n+/// Compute connected components over the voted-pairs graph (treated as undirected).\n+///\n+/// Returns:\n+/// - `components`: each component is a sorted list of node indices, excluding isolates.\n+/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).\n+pub fn connected_components_from_voted_pairs(\n+    n: usize,\n+    voted_pairs: impl Iterator,\n+) -> (Vec>, Vec) {\n+    let mut adj: Vec> = vec![Vec::new(); n];\n+    for (a, b) in voted_pairs {\n+        if a >= n || b >= n || a == b {\n+            continue;\n+        }\n+        adj[a].push(b);\n+        adj[b].push(a);\n+    }\n+\n+    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();\n+    isolates.sort();\n+\n+    let mut seen = vec![false; n];\n+    for &i in &isolates {\n+        seen[i] = true;\n+    }\n+\n+    let mut comps: Vec> = Vec::new();\n+    for i in 0..n {\n+        if seen[i] {\n+            continue;\n+        }\n+        let mut stack = vec![i];\n+        seen[i] = true;\n+        let mut comp: Vec = Vec::new();\n+        while let Some(x) = stack.pop() {\n+            comp.push(x);\n+            for &y in &adj[x] {\n+                if !seen[y] {\n+                    seen[y] = true;\n+                    stack.push(y);\n+                }\n+            }\n+        }\n+        comp.sort();\n+        comps.push(comp);\n+    }\n+\n+    (comps, isolates)\n+}\n+\n+/// Compute rank centrality scores for a GroupState.\n+/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing\n+/// an O(E) multiply per iteration.\n+pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {\n+    if !group.dirty && !group.cached_scores.is_empty() {\n+        return;\n+    }\n+\n+    let n = group.idx_to_item.len();\n+    if n == 0 {\n+        group.cached_scores = vec![];\n+        group.dirty = false;\n+        return;\n+    }\n+    if n == 1 {\n+        group.cached_scores = vec![1.0];\n+        group.dirty = false;\n+        return;\n+    }\n+\n+    let scores = compute_scores_from_edges(\n+        n,\n+        group.edges.iter().map(|(&k, &w)| (k, w)),\n+        max_iters,\n+        tol,\n+    );\n+    group.cached_scores = scores;\n+    group.dirty = false;\n+}\n+\n+pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {\n+    compute_group_ranking(group, max_iters, tol);\n+    let mut items: Vec = group\n+        .idx_to_item\n+        .iter()\n+        .enumerate()\n+        .map(|(i, item)| RankedItem {\n+            item: item.clone(),\n+            score: *group.cached_scores.get(i).unwrap_or(&0.0),\n+        })\n+        .collect();\n+\n+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+    items\n+}\n+\n+fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {\n+    if n == 0 {\n+        return vec![];\n+    }\n+    if n == 1 {\n+        return vec![1.0];\n+    }\n+\n+    // Collect raw edges into a map for pairwise normalization.\n+    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();\n+    for ((src, dst), w) in edges {\n+        if src >= n || dst >= n || w <= 0.0 {\n+            continue;\n+        }\n+        *raw.entry((src, dst)).or_insert(0.0) += w;\n+    }\n+\n+    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).\n+    // This ensures repeated votes on the same pair don't inflate influence\n+    // beyond what the ratio implies.\n+    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();\n+    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();\n+    for (i, j) in keys {\n+        if normalized.contains_key(&(i, j)) {\n+            continue;\n+        }\n+        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);\n+        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);\n+        let total = w_ij + w_ji;\n+        if total <= 0.0 {\n+            continue;\n+        }\n+        normalized.insert((i, j), w_ij / total);\n+        if w_ji > 0.0 {\n+            normalized.insert((j, i), w_ji / total);\n+        }\n+    }\n+\n+    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):\n+    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared\n+    //   P_ii = 1 - (1/d_max) * Σ_k A_ik\n+    // where d_i is the *degree* (number of distinct neighbors compared) and\n+    // d_max = max_i d_i. Using the unweighted degree — not the sum of\n+    // pairwise-normalized weights — is what guarantees aperiodicity: it\n+    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree\n+    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+    // loss). Without this, regular comparison graphs (e.g. a pure star at\n+    // ratio 2:1) produce a bipartite chain that oscillates instead of\n+    // converging — see issue #146.\n+    let mut out_edges: Vec> = vec![Vec::new(); n];\n+    let mut neighbors: Vec> = vec![HashSet::new(); n];\n+\n+    for ((src, dst), w) in &normalized {\n+        out_edges[*src].push((*dst, *w));\n+        neighbors[*src].insert(*dst);\n+        neighbors[*dst].insert(*src);\n+    }\n+\n+    let weight_sum: Vec = out_edges\n+        .iter()\n+        .map(|es| es.iter().map(|(_, w)| *w).sum())\n+        .collect();\n+    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+    if d_max == 0 {\n+        return vec![1.0 / n as f64; n];\n+    }\n+    let d_max_f = d_max as f64;\n+\n+    let mut scores = vec![1.0 / n as f64; n];\n+    let mut next = vec![0.0f64; n];\n+\n+    for _ in 0..max_iters {\n+        next.fill(0.0);\n+        for i in 0..n {\n+            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;\n+            next[i] += scores[i] * stay_prob;\n+\n+            if out_edges[i].is_empty() {\n+                continue;\n+            }\n+            for &(dst, w) in &out_edges[i] {\n+                next[dst] += scores[i] * (w / d_max_f);\n+            }\n+        }\n+\n+        let diff: f64 = scores\n+            .iter()\n+            .zip(next.iter())\n+            .map(|(a, b)| (a - b).abs())\n+            .sum();\n+\n+        scores.clone_from_slice(&next);\n+        if diff < tol {\n+            break;\n+        }\n+    }\n+\n+    let sum: f64 = scores.iter().sum();\n+    if sum.is_finite() && sum > 0.0 {\n+        for s in &mut scores {\n+            *s /= sum;\n+        }\n+    }\n+    scores\n+}\n+\n+/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.\n+///\n+/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.\n+pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {\n+    if idxs.is_empty() {\n+        return vec![];\n+    }\n+\n+    // Map original idx -> compact idx [0..m)\n+    let mut map: HashMap = HashMap::with_capacity(idxs.len());\n+    for (j, &i) in idxs.iter().enumerate() {\n+        map.insert(i, j);\n+    }\n+\n+    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {\n+        let s = *map.get(&src)?;\n+        let d = *map.get(&dst)?;\n+        Some(((s, d), w))\n+    });\n+\n+    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);\n+\n+    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).\n+    let mut items: Vec = idxs\n+        .iter()\n+        .enumerate()\n+        .filter_map(|(j, &orig)| {\n+            let item = group.idx_to_item.get(orig)?.clone();\n+            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })\n+        })\n+        .collect();\n+\n+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+    items\n+}\n+\n+pub fn group_summary_scores(\n+    group: &mut GroupState,\n+    max_iters: usize,\n+    tol: f64,\n+) -> HashMap {\n+    ranked_items(group, max_iters, tol)\n+        .into_iter()\n+        .map(|r| (r.item, r.score))\n+        .collect()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+    use crate::reducer::VoteData;\n+\n+    fn mk_group() -> GroupState {\n+        GroupState::new()\n+    }\n+\n+    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+        use crate::path_types::ItemId;\n+        VoteData {\n+            ts,\n+            a: ItemId::parse(a).unwrap(),\n+            b: ItemId::parse(b).unwrap(),\n+            ratio_left: l,\n+            ratio_right: r,\n+            body: \"because\".to_string(),\n+            principal: \"test\".to_string(),\n+            delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n+            thread_tag: \"untagged\".to_string(),\n+        }\n+    }\n+\n+    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n+    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+    /// chain was bipartite; power iteration oscillated and returned the\n+    /// uniform initial distribution after an even number of steps. Using the\n+    /// paper's degree-based d_max gives every node a positive self-loop and\n+    /// the chain converges to the correct stationary distribution.\n+    #[test]\n+    fn star_topology_winner_at_top_via_subset() {\n+        let mut g = mk_group();\n+        g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+        g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+        let mut items: Vec<(usize, String)> = g\n+            .idx_to_item\n+            .iter()\n+            .enumerate()\n+            .map(|(i, it)| (i, it.as_str().to_string()))\n+            .collect();\n+        items.sort_by(|a, b| a.1.cmp(&b.1));\n+        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+        for r in &ranked {\n+            eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+        }\n+        assert_eq!(\n+            ranked[0].item.as_str(),\n+            \"https://slug.social/zebra\",\n+            \"zebra won both votes and should rank #1\"\n+        );\n+    }\n+\n+    #[test]\n+    fn group_ranking_cache_dirty_flow() {\n+        let mut g = mk_group();\n+        assert!(g.dirty);\n+\n+        g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+        assert!(g.dirty);\n+        assert!(g.cached_scores.is_empty());\n+\n+        compute_group_ranking(&mut g, 10000, 1e-8);\n+        assert!(!g.dirty);\n+        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());\n+\n+        // Recomputing when not dirty should be a no-op.\n+        let before = g.cached_scores.clone();\n+        compute_group_ranking(&mut g, 10000, 1e-8);\n+        assert_eq!(before, g.cached_scores);\n+    }\n+\n+    #[test]\n+    fn connected_components_split_disconnected_pairs() {\n+        let mut g = mk_group();\n+        // Two disconnected edges: (a,b) and (c,d)\n+        g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+        g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n+\n+        let n = g.idx_to_item.len();\n+        let (mut comps, isolates) =\n+            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());\n+        assert!(isolates.is_empty());\n+        // Order-independent: sort components by their item names for stable assert.\n+        comps.sort_by_key(|c| {\n+            c.iter()\n+                .map(|&i| g.idx_to_item[i].clone())\n+                .collect::>()\n+        });\n+        assert_eq!(comps.len(), 2);\n+        let comp0 = comps[0]\n+            .iter()\n+            .map(|&i| g.idx_to_item[i].as_str())\n+            .collect::>();\n+        let comp1 = comps[1]\n+            .iter()\n+            .map(|&i| g.idx_to_item[i].as_str())\n+            .collect::>();\n+        assert_eq!(comp0, vec![\"https://slug.social/a\", \"https://slug.social/b\"]);\n+        assert_eq!(comp1, vec![\"https://slug.social/c\", \"https://slug.social/d\"]);\n+    }\n+\n+    #[test]\n+    fn subset_ranking_ranks_within_component_only() {\n+        let mut g = mk_group();\n+        g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n+        g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n+\n+        let (comps, _) =\n+            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());\n+        assert_eq!(comps.len(), 2);\n+\n+        // Rank each component and ensure winner is first within that component.\n+        for comp in comps {\n+            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);\n+            assert_eq!(ranked.len(), 2);\n+            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();\n+            if names.contains(&\"https://slug.social/a\") {\n+                assert_eq!(names[0], \"https://slug.social/a\");\n+            } else {\n+                assert_eq!(names[0], \"https://slug.social/d\");\n+            }\n+        }\n+    }\n+}\n+\n+\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  \n+\ndiff --git a/legacy/reddit.rs b/legacy/reddit.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..da27f6f76482dd27d44cad9d7b844972bf042e64\n--- /dev/null\n+++ b/legacy/reddit.rs\n@@ -0,0 +1,420 @@\n+use governor::{Quota, RateLimiter, Jitter};\n+use nonzero_ext::nonzero;\n+use reqwest::{Client, StatusCode};\n+use serde::{Deserialize, Serialize};\n+use std::sync::Arc;\n+use std::time::Duration;\n+use anyhow::{Result, Context, anyhow};\n+\n+/// Reddit API client with built-in rate limiting\n+pub struct RedditClient {\n+    client: Client,\n+    limiter: Arc,\n+    user_agent: String,\n+}\n+\n+impl RedditClient {\n+    /// Create a new Reddit client with rate limiting\n+    /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps\n+    /// We'll be conservative and use 50 requests per minute\n+    pub fn new() -> Self {\n+        // Create rate limiter: 50 requests per minute\n+        let quota = Quota::per_minute(nonzero!(50u32));\n+        let limiter = Arc::new(RateLimiter::direct(quota));\n+        \n+        // Create HTTP client with timeout\n+        let client = Client::builder()\n+            .timeout(Duration::from_secs(30))\n+            .build()\n+            .expect(\"Failed to create HTTP client\");\n+        \n+        // Reddit requires a unique user agent\n+        let user_agent = format!(\n+            \"rust:sorter:v{} (by /u/hourLong_arnould)\",\n+            env!(\"CARGO_PKG_VERSION\")\n+        );\n+        \n+        Self {\n+            client,\n+            limiter,\n+            user_agent,\n+        }\n+    }\n+    \n+    /// Fetch hot posts from a subreddit\n+    pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result {\n+        // Wait for rate limiter\n+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n+        \n+        let url = format!(\"https://www.reddit.com/r/{}/hot.json?limit={}\", subreddit, limit);\n+        \n+        let response = self.client\n+            .get(&url)\n+            .header(\"User-Agent\", &self.user_agent)\n+            .send()\n+            .await\n+            .context(\"Failed to send request to Reddit\")?;\n+        \n+        self.handle_response(response).await\n+    }\n+    \n+    /// Fetch top posts from a subreddit\n+    pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result {\n+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n+        \n+        let url = format!(\n+            \"https://www.reddit.com/r/{}/top.json?limit={}&t={}\",\n+            subreddit, limit, time_period\n+        );\n+        \n+        let response = self.client\n+            .get(&url)\n+            .header(\"User-Agent\", &self.user_agent)\n+            .send()\n+            .await\n+            .context(\"Failed to send request to Reddit\")?;\n+        \n+        self.handle_response(response).await\n+    }\n+    \n+    /// Fetch new posts from a subreddit\n+    pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result {\n+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n+        \n+        let url = format!(\"https://www.reddit.com/r/{}/new.json?limit={}\", subreddit, limit);\n+        \n+        let response = self.client\n+            .get(&url)\n+            .header(\"User-Agent\", &self.user_agent)\n+            .send()\n+            .await\n+            .context(\"Failed to send request to Reddit\")?;\n+        \n+        self.handle_response(response).await\n+    }\n+    \n+    /// Fetch a specific post and its comments\n+    pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result> {\n+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n+        \n+        let url = format!(\n+            \"https://www.reddit.com/r/{}/comments/{}.json\",\n+            subreddit, post_id\n+        );\n+        \n+        let response = self.client\n+            .get(&url)\n+            .header(\"User-Agent\", &self.user_agent)\n+            .send()\n+            .await\n+            .context(\"Failed to send request to Reddit\")?;\n+        \n+        match response.status() {\n+            StatusCode::OK => {\n+                let listings: Vec = response\n+                    .json()\n+                    .await\n+                    .context(\"Failed to parse Reddit response\")?;\n+                Ok(listings)\n+            }\n+            StatusCode::TOO_MANY_REQUESTS => {\n+                Err(anyhow!(\"Reddit rate limit exceeded. Please try again later.\"))\n+            }\n+            StatusCode::NOT_FOUND => {\n+                Err(anyhow!(\"Post not found: r/{}/comments/{}\", subreddit, post_id))\n+            }\n+            status => {\n+                Err(anyhow!(\"Reddit API error: {}\", status))\n+            }\n+        }\n+    }\n+    \n+    /// Fetch user profile (posts and comments)\n+    pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result {\n+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n+        \n+        let url = format!(\n+            \"https://www.reddit.com/user/{}/{}.json?limit={}\",\n+            username, content_type, limit\n+        );\n+        \n+        let response = self.client\n+            .get(&url)\n+            .header(\"User-Agent\", &self.user_agent)\n+            .send()\n+            .await\n+            .context(\"Failed to send request to Reddit\")?;\n+        \n+        self.handle_response(response).await\n+    }\n+    \n+    /// Handle Reddit API response with proper error checking\n+    async fn handle_response(&self, response: reqwest::Response) -> Result {\n+        match response.status() {\n+            StatusCode::OK => {\n+                let listing: RedditListingResponse = response\n+                    .json()\n+                    .await\n+                    .context(\"Failed to parse Reddit response\")?;\n+                Ok(listing)\n+            }\n+            StatusCode::TOO_MANY_REQUESTS => {\n+                Err(anyhow!(\"Reddit rate limit exceeded. Please try again later.\"))\n+            }\n+            StatusCode::NOT_FOUND => {\n+                Err(anyhow!(\"Reddit resource not found\"))\n+            }\n+            StatusCode::FORBIDDEN => {\n+                Err(anyhow!(\"Access forbidden. The subreddit may be private.\"))\n+            }\n+            status => {\n+                Err(anyhow!(\"Reddit API error: {}\", status))\n+            }\n+        }\n+    }\n+}\n+\n+// Reddit API response types\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+pub struct RedditListingResponse {\n+    pub kind: String,\n+    pub data: RedditListingData,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+pub struct RedditListingData {\n+    #[serde(skip_serializing_if = \"Option::is_none\")]\n+    pub after: Option,\n+    #[serde(skip_serializing_if = \"Option::is_none\")]\n+    pub before: Option,\n+    pub children: Vec,\n+    #[serde(skip_serializing_if = \"Option::is_none\")]\n+    pub modhash: Option,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+pub struct RedditThing {\n+    pub kind: String,\n+    pub data: RedditThingData,\n+}\n+\n+/// Reddit's \"Thing\" data can be either a post, comment, or other types\n+/// We use untagged enum to handle different data structures\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+#[serde(untagged)]\n+pub enum RedditThingData {\n+    Post(RedditPost),\n+    Comment(RedditComment),\n+    // For things we don't care about yet (like \"more\" comments)\n+    Other(serde_json::Value),\n+}\n+\n+/// Reddit post data with serde handling all the parsing\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+pub struct RedditPost {\n+    pub id: String,\n+    pub name: String, // Full name (t3_xxx)\n+    #[serde(default = \"default_untitled\")]\n+    pub title: String,\n+    #[serde(default = \"default_deleted_user\")]\n+    pub author: String,\n+    pub subreddit: String,\n+    #[serde(default)]\n+    pub url: String,\n+    #[serde(default)]\n+    pub permalink: String,\n+    #[serde(default, deserialize_with = \"deserialize_selftext\")]\n+    pub selftext: Option,\n+    #[serde(default)]\n+    pub score: i64,\n+    #[serde(default)]\n+    pub num_comments: i64,\n+    #[serde(default)]\n+    pub created_utc: f64,\n+    #[serde(default, deserialize_with = \"deserialize_thumbnail\")]\n+    pub thumbnail: Option,\n+    #[serde(default)]\n+    pub is_video: bool,\n+    #[serde(default)]\n+    pub is_self: bool,\n+    // Additional useful fields\n+    #[serde(default)]\n+    pub ups: i64,\n+    #[serde(default)]\n+    pub downs: i64,\n+    #[serde(default)]\n+    pub upvote_ratio: f64,\n+    #[serde(default)]\n+    pub over_18: bool,\n+    #[serde(default)]\n+    pub spoiler: bool,\n+    #[serde(default)]\n+    pub stickied: bool,\n+    #[serde(default)]\n+    pub locked: bool,\n+    #[serde(default)]\n+    pub distinguished: Option,\n+}\n+\n+/// Reddit comment data\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+pub struct RedditComment {\n+    pub id: String,\n+    pub name: String, // Full name (t1_xxx)\n+    #[serde(default = \"default_deleted_user\")]\n+    pub author: String,\n+    #[serde(default)]\n+    pub body: String,\n+    #[serde(default)]\n+    pub score: i64,\n+    #[serde(default)]\n+    pub created_utc: f64,\n+    #[serde(default)]\n+    pub parent_id: String,\n+    #[serde(default)]\n+    pub permalink: String,\n+    #[serde(default)]\n+    pub depth: i32,\n+    // Additional useful fields\n+    #[serde(default)]\n+    pub ups: i64,\n+    #[serde(default)]\n+    pub downs: i64,\n+    #[serde(default)]\n+    pub edited: RedditEditStatus,\n+    #[serde(default)]\n+    pub stickied: bool,\n+    #[serde(default)]\n+    pub distinguished: Option,\n+    #[serde(default)]\n+    pub is_submitter: bool,\n+    #[serde(default)]\n+    pub collapsed: bool,\n+    #[serde(default)]\n+    pub controversiality: i32,\n+}\n+\n+/// Reddit's edit status - can be false or a timestamp\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+#[serde(untagged)]\n+pub enum RedditEditStatus {\n+    NotEdited(bool),\n+    EditedAt(f64),\n+}\n+\n+impl Default for RedditEditStatus {\n+    fn default() -> Self {\n+        RedditEditStatus::NotEdited(false)\n+    }\n+}\n+\n+// Helper functions for serde defaults\n+fn default_untitled() -> String {\n+    \"Untitled\".to_string()\n+}\n+\n+fn default_deleted_user() -> String {\n+    \"[deleted]\".to_string()\n+}\n+\n+// Custom deserializer for selftext (empty strings should be None)\n+fn deserialize_selftext<'de, D>(deserializer: D) -> Result, D::Error>\n+where\n+    D: serde::Deserializer<'de>,\n+{\n+    let s: Option = Option::deserialize(deserializer)?;\n+    Ok(s.filter(|text| !text.is_empty()))\n+}\n+\n+// Custom deserializer for thumbnail (filter out \"self\", \"default\", empty)\n+fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result, D::Error>\n+where\n+    D: serde::Deserializer<'de>,\n+{\n+    let s: Option = Option::deserialize(deserializer)?;\n+    Ok(s.filter(|thumb| {\n+        !thumb.is_empty() && thumb != \"self\" && thumb != \"default\" && thumb != \"nsfw\" && thumb != \"spoiler\"\n+    }))\n+}\n+\n+// Helper methods for extracting typed data from Things\n+impl RedditThing {\n+    /// Try to get this Thing as a Post\n+    pub fn as_post(&self) -> Option<&RedditPost> {\n+        if self.kind != \"t3\" {\n+            return None;\n+        }\n+        match &self.data {\n+            RedditThingData::Post(post) => Some(post),\n+            _ => None,\n+        }\n+    }\n+    \n+    /// Try to get this Thing as a Comment\n+    pub fn as_comment(&self) -> Option<&RedditComment> {\n+        if self.kind != \"t1\" {\n+            return None;\n+        }\n+        match &self.data {\n+            RedditThingData::Comment(comment) => Some(comment),\n+            _ => None,\n+        }\n+    }\n+    \n+    /// Check if this is a \"more comments\" indicator\n+    pub fn is_more_comments(&self) -> bool {\n+        self.kind == \"more\"\n+    }\n+}\n+\n+// Helper methods for working with listing responses\n+impl RedditListingResponse {\n+    /// Extract all posts from this listing\n+    pub fn get_posts(&self) -> Vec<&RedditPost> {\n+        self.data.children.iter()\n+            .filter_map(|thing| thing.as_post())\n+            .collect()\n+    }\n+    \n+    /// Extract all comments from this listing\n+    pub fn get_comments(&self) -> Vec<&RedditComment> {\n+        self.data.children.iter()\n+            .filter_map(|thing| thing.as_comment())\n+            .collect()\n+    }\n+    \n+    /// Extract posts as owned values\n+    pub fn into_posts(self) -> Vec {\n+        self.data.children.into_iter()\n+            .filter_map(|thing| {\n+                if thing.kind == \"t3\" {\n+                    match thing.data {\n+                        RedditThingData::Post(post) => Some(post),\n+                        _ => None,\n+                    }\n+                } else {\n+                    None\n+                }\n+            })\n+            .collect()\n+    }\n+    \n+    /// Extract comments as owned values\n+    pub fn into_comments(self) -> Vec {\n+        self.data.children.into_iter()\n+            .filter_map(|thing| {\n+                if thing.kind == \"t1\" {\n+                    match thing.data {\n+                        RedditThingData::Comment(comment) => Some(comment),\n+                        _ => None,\n+                    }\n+                } else {\n+                    None\n+                }\n+            })\n+            .collect()\n+    }\n+}\n+\n+\n+\ndiff --git a/legacy/reducer.rs b/legacy/reducer.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..1ba8030feaf783b066b2e0bba4d2d05659c992f4\n--- /dev/null\n+++ b/legacy/reducer.rs\n@@ -0,0 +1,845 @@\n+use std::collections::{HashMap, HashSet, VecDeque};\n+\n+use serde::{Deserialize, Serialize};\n+\n+use crate::canonical_path::canonicalize_tag;\n+use crate::dsl;\n+use crate::events::{Event, Ingest, ThreadCapability};\n+use crate::path_types::ItemId;\n+\n+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n+pub enum ScopeId {\n+    Public,\n+    Room(String),\n+}\n+\n+/// Wire `room` field → content scope (`\"public\"` → [`ScopeId::Public`]).\n+pub fn scope_from_room_wire(room: &str) -> ScopeId {\n+    let r = room.trim();\n+    if r.is_empty() || r == \"public\" {\n+        ScopeId::Public\n+    } else {\n+        ScopeId::Room(r.to_string())\n+    }\n+}\n+\n+/// Parsed vote data (internal representation).\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct VoteData {\n+    pub ts: i64,\n+    pub a: ItemId,\n+    pub b: ItemId,\n+    pub ratio_left: i32,\n+    pub ratio_right: i32,\n+    pub body: String,\n+    pub principal: String,\n+    pub delegate: Option,\n+    /// Forum channel where this vote was cast (tag only, not room id).\n+    pub thread_tag: String,\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct GroupState {\n+    pub item_to_idx: HashMap,\n+    pub idx_to_item: Vec,\n+\n+    /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.\n+    pub edges: HashMap<(usize, usize), f64>,\n+\n+    /// Unordered pairs that have at least one vote recorded between them (i,\n+    pub dirty: bool,\n+    pub cached_scores: Vec,\n+    pub recent_votes: VecDeque,\n+}\n+\n+impl Default for GroupState {\n+    fn default() -> Self {\n+        Self::new()\n+    }\n+}\n+\n+impl GroupState {\n+    pub fn new() -> Self {\n+        Self {\n+            item_to_idx: HashMap::new(),\n+            idx_to_item: Vec::new(),\n+            edges: HashMap::new(),\n+            voted_pairs: HashSet::new(),\n+            dirty: true,\n+            cached_scores: Vec::new(),\n+            recent_votes: VecDeque::with_capacity(200),\n+        }\n+    }\n+\n+    fn ensure_item(&mut self, item: &ItemId) -> usize {\n+        if let Some(&idx) = self.item_to_idx.get(item) {\n+            return idx;\n+        }\n+        let idx = self.idx_to_item.len();\n+        self.idx_to_item.push(item.clone());\n+        self.item_to_idx.insert(item.clone(), idx);\n+        self.dirty = true;\n+        idx\n+    }\n+\n+    /// Public test helper: insert an item into the group without a vote (for unit tests).\n+    pub fn ensure_item_pub(&mut self, item: &str) -> usize {\n+        if let Some(canon) = ItemId::parse(item) {\n+            self.ensure_item(&canon)\n+        } else {\n+            // Fallback: treat as raw storage string\n+            let canon = ItemId::opaque(item.to_string());\n+            self.ensure_item(&canon)\n+        }\n+    }\n+\n+    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n+        if w <= 0.0 {\n+            return;\n+        }\n+        *self.edges.entry((src, dst)).or_insert(0.0) += w;\n+        self.dirty = true;\n+    }\n+\n+    pub fn apply_vote(&mut self, mut vote: VoteData) {\n+        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n+        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n+        vote.thread_tag = canonicalize_tag(&vote.thread_tag);\n+        if vote.ratio_left < 0 {\n+            vote.ratio_left = 0;\n+        }\n+        if vote.ratio_right < 0 {\n+            vote.ratio_right = 0;\n+        }\n+        let a_idx = self.ensure_item(&vote.a);\n+        let b_idx = self.ensure_item(&vote.b);\n+\n+        let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };\n+        self.voted_pairs.insert((i, j));\n+\n+        let w_a = vote.ratio_left as f64;\n+        let w_b = vote.ratio_right as f64;\n+\n+        self.add_edge_weight(b_idx, a_idx, w_a);\n+        self.add_edge_weight(a_idx, b_idx, w_b);\n+\n+        self.recent_votes.push_front(vote);\n+        while self.recent_votes.len() > 200 {\n+            self.recent_votes.pop_back();\n+        }\n+    }\n+}\n+\n+/// Compact rank-history entry stored per item in the ledger.\n+/// `caused_by` is resolved lazily at query time from `ingests_by_id`.\n+#[derive(Debug, Clone)]\n+pub struct RankHistoryEntry {\n+    pub ts: i64,\n+    pub scope_rank: usize,\n+    pub scope_rank_delta: i32,\n+    pub scope_total: usize,\n+    pub global_rank: usize,\n+    pub global_rank_delta: i32,\n+    pub global_total: usize,\n+    pub score: f64,\n+    pub thread: String,\n+    pub post_id: String,\n+}\n+\n+/// Invite link materialized from log events [`crate::events::InviteMinted`] /\n+/// [`crate::events::InviteRedeemed`] when those are replayed.\n+///\n+/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);\n+/// they are not appended to the JSONL log, so they disappear on restart. This struct is for\n+/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.\n+#[derive(Debug, Clone)]\n+pub struct ActiveInviteState {\n+    pub room_id: String,\n+    pub capabilities: HashSet,\n+    pub inviter: String,\n+    pub uses_remaining: u32,\n+    pub expires_ts_ms: Option,\n+}\n+\n+#[derive(Clone, Debug)]\n+pub enum RoomTimelineKind {\n+    RoomCreated {\n+        owner: String,\n+        slug: String,\n+    },\n+    RoomDeleted {\n+        deleted_by: String,\n+    },\n+    GrantAdded {\n+        username: String,\n+        granted_by: String,\n+        capabilities: Vec,\n+    },\n+    GrantRevoked {\n+        username: String,\n+        revoked_by: String,\n+        capabilities: Vec,\n+    },\n+}\n+\n+#[derive(Clone, Debug)]\n+pub struct RoomTimelineEntry {\n+    pub ts: i64,\n+    pub kind: RoomTimelineKind,\n+}\n+\n+#[derive(Debug, Clone)]\n+#[derive(Default)]\n+pub struct ForumThreadState {\n+    pub last_activity_ts: i64,\n+    /// Username of the most recent person who bumped this thread.\n+    pub last_actor: String,\n+}\n+\n+\n+#[derive(Debug, Clone, Default)]\n+pub struct ContentState {\n+    pub ranking_group: GroupState,\n+    pub items: HashSet,\n+    pub item_bodies: HashMap,\n+    /// Parent [`ItemId`] -> direct children.\n+    pub item_children: HashMap>,\n+    /// Per-item vote history (most recent first).\n+    pub item_votes: HashMap>,\n+    /// Per-item ingest references (most recent first).\n+    pub item_snippets: HashMap>,\n+    /// Item path -> threads that mention or vote on this item.\n+    pub item_threads: HashMap>,\n+    /// Per-item rank history, oldest first.\n+    pub rank_history: HashMap>,\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct ReducerState {\n+    pub content: HashMap,\n+\n+    /// (provider, provider_id) -> username\n+    pub users_by_provider: HashMap<(String, String), String>,\n+    /// token_id -> (username, salt, token_hash)\n+    pub tokens_by_id: HashMap,\n+    pub agent_bindings: HashMap,\n+\n+\n+    pub ingests_by_id: HashMap,\n+    /// (scope, thread_tag) → ingest ids, newest first.\n+    pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,\n+    /// Private room ids (`shortid/slug`) known from [`RoomCreated`].\n+    pub rooms: HashSet,\n+    /// (scope, thread_tag) → last activity.\n+    pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,\n+    pub actor_last_post_ts: HashMap,\n+\n+    /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.\n+    pub ingests_ordered: Vec,\n+    /// Username → ingest ids in post order (oldest first), for profile pages.\n+    pub posts_by_actor: HashMap>,\n+    /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).\n+    pub redacted_posts: HashSet,\n+    /// Redaction event timestamp (ms) per post id.\n+    pub post_redact_ts: HashMap,\n+    /// room_id → username → capabilities\n+    pub grants: HashMap>>,\n+    /// room_id → chronological room admin lines (for thread UI).\n+    pub room_timeline: HashMap>,\n+    /// Invite token → active invite (absent when fully consumed or never minted).\n+    pub invites: HashMap,\n+}\n+\n+impl ReducerState {\n+    pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {\n+        self.content.get(scope)\n+    }\n+\n+    /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).\n+    pub fn try_thread_post_index_chronological(\n+        &self,\n+        scope: &ScopeId,\n+        thread_tag: &str,\n+        post_id: &str,\n+    ) -> Option {\n+        let tag = canonicalize_tag(thread_tag);\n+        self.ingests_by_scope_thread\n+            .get(&(scope.clone(), tag))\n+            .and_then(|q| q.iter().rev().position(|pid| pid == post_id))\n+    }\n+\n+    pub fn thread_post_index_chronological(\n+        &self,\n+        scope: &ScopeId,\n+        thread_tag: &str,\n+        post_id: &str,\n+    ) -> usize {\n+        self.try_thread_post_index_chronological(scope, thread_tag, post_id)\n+            .expect(\n+                \"post_id must appear in ingests_by_scope_thread for this scope and thread\",\n+            )\n+    }\n+\n+    /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.\n+    pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {\n+        self.posts_by_actor\n+            .get(actor)\n+            .map(|q| q.iter().cloned().collect())\n+            .unwrap_or_default()\n+    }\n+\n+    /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.\n+    pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {\n+        self.posts_by_actor_ids(actor)\n+            .into_iter()\n+            .filter(|id| {\n+                !self.redacted_posts.contains(id)\n+                    && self.ingests_by_id.get(id).is_some_and(|ing| {\n+                        let scope = scope_from_room_wire(&ing.room_id);\n+                        match &scope {\n+                            ScopeId::Public => true,\n+                            ScopeId::Room(rid) => {\n+                                viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))\n+                            }\n+                        }\n+                    })\n+            })\n+            .collect()\n+    }\n+\n+    pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {\n+        self.grants\n+            .get(room_id)\n+            .and_then(|t| t.get(username))\n+            .map(|caps| caps.contains(&cap))\n+            .unwrap_or(false)\n+    }\n+\n+    /// Invite link is present, not expired, and has uses left.\n+    pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {\n+        let inv = self.invites.get(token)?;\n+        if inv.uses_remaining == 0 {\n+            return None;\n+        }\n+        if let Some(exp) = inv.expires_ts_ms {\n+            if now_ms > exp {\n+                return None;\n+            }\n+        }\n+        Some(inv)\n+    }\n+\n+    pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {\n+        self.content.entry(scope).or_default()\n+    }\n+\n+    pub fn public(&self) -> &ContentState {\n+        self.content.get(&ScopeId::Public).expect(\"public scope missing\")\n+    }\n+\n+    /// Register parent→child edges for the full ancestor chain.\n+    /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `\"\"→a`.\n+    /// Stops early when an intermediate is already registered (its ancestors must be too).\n+    /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4\n+    fn add_child_edge(content: &mut ContentState, item: &ItemId) {\n+        let mut child = item.clone();\n+        loop {\n+            let Some(parent) = child.parent() else { break };\n+            let is_new = content\n+                .item_children\n+                .entry(parent.clone())\n+                .or_default()\n+                .insert(child);\n+            if !is_new { break; }\n+            child = parent;\n+        }\n+    }\n+\n+    /// Resolve an item path as a first-class [`ItemId`].\n+    fn normalize_item(item: &str) -> Option {\n+        ItemId::parse(item)\n+    }\n+\n+    /// 1-indexed rank of `item` within its connected component in the parent scope.\n+    /// 0 if the item has no votes connecting it to siblings (unranked).\n+    fn scope_rank_of(\n+        group: &GroupState,\n+        item: &ItemId,\n+        item_children: &HashMap>,\n+    ) -> usize {\n+        let scope = match item.parent() {\n+            Some(p) => p,\n+            None => return 0,\n+        };\n+        let children = match item_children.get(&scope) {\n+            None => return 0,\n+            Some(c) => c,\n+        };\n+        let &item_global_idx = match group.item_to_idx.get(item) {\n+            None => return 0,\n+            Some(i) => i,\n+        };\n+        // Map scope children to compact local indices.\n+        let sibling_idxs: Vec = children.iter()\n+            .filter_map(|c| group.item_to_idx.get(c).copied())\n+            .collect();\n+        let global_to_local: HashMap = sibling_idxs.iter()\n+            .enumerate().map(|(l, &g)| (g, l)).collect();\n+        let item_local = match global_to_local.get(&item_global_idx) {\n+            None => return 0,\n+            Some(&l) => l,\n+        };\n+        // Connected components within scope.\n+        let (comps, _) = crate::ranking::connected_components_from_voted_pairs(\n+            sibling_idxs.len(),\n+            group.voted_pairs.iter().filter_map(|(a, b)| {\n+                Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))\n+            }),\n+        );\n+        // Find the component containing this item.\n+        let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {\n+            None => return 0,\n+            Some(c) => c,\n+        };\n+        let comp_global: Vec = comp_local.iter()\n+            .filter_map(|&l| sibling_idxs.get(l).copied())\n+            .collect();\n+        let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);\n+        ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)\n+    }\n+\n+    /// 1-indexed position of `item` in the component-aware global flat list.\n+    /// Components sorted largest-first; items ranked within each component.\n+    /// 0 if the item is not in the ranking group.\n+    fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {\n+        if !group.item_to_idx.contains_key(item) {\n+            return 0;\n+        }\n+        let n = group.idx_to_item.len();\n+        let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(\n+            n, group.voted_pairs.iter().copied(),\n+        );\n+        comps.sort_by_key(|b| std::cmp::Reverse(b.len()));\n+        let mut pos = 1usize;\n+        for comp in &comps {\n+            let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);\n+            for r in &ranked {\n+                if &r.item == item {\n+                    return pos;\n+                }\n+                pos += 1;\n+            }\n+        }\n+        0\n+    }\n+\n+    /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).\n+    fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {\n+        let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;\n+        let canonical_thread = canonicalize_tag(&ing.thread_tag);\n+\n+        let voted_items: Vec = doc\n+            .statements\n+            .iter()\n+            .filter_map(|s| {\n+                if let dsl::Stmt::Vote { item1, item2, .. } = s {\n+                    Some([item1, item2])\n+                } else {\n+                    None\n+                }\n+            })\n+            .flat_map(|pair| pair.into_iter())\n+            .filter_map(|raw| Self::normalize_item(raw))\n+            .collect::>()\n+            .into_iter()\n+            .collect();\n+\n+        let principal = ing.principal.clone();\n+        let delegate = ing.delegate.clone();\n+\n+        let before: HashMap = if !voted_items.is_empty() {\n+            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n+            voted_items\n+                .iter()\n+                .map(|it| {\n+                    (\n+                        it.clone(),\n+                        (\n+                            Self::scope_rank_of(&content.ranking_group, it, &content.item_children),\n+                            Self::global_rank_of(&content.ranking_group, it),\n+                        ),\n+                    )\n+                })\n+                .collect()\n+        } else {\n+            HashMap::new()\n+        };\n+\n+        let mut ingest_items: HashSet = HashSet::new();\n+\n+        for stmt in doc.statements {\n+            match stmt {\n+                dsl::Stmt::Item { title, body } => {\n+                    let Some(item) = Self::normalize_item(&title) else {\n+                        continue;\n+                    };\n+                    nav!(content.items, set_elem(item.clone()));\n+                    ingest_items.insert(item.clone());\n+                    Self::add_child_edge(content, &item);\n+\n+                    if let Some(body_text) = body {\n+                        if !body_text.trim().is_empty() {\n+                            nav!(content.item_bodies, keypath(item.clone()), setval(body_text));\n+                        }\n+                    }\n+                }\n+                dsl::Stmt::Vote {\n+                    item1,\n+                    item2,\n+                    ratio_left,\n+                    ratio_right,\n+                    explanation,\n+                } => {\n+                    let Some(item_a) = Self::normalize_item(&item1) else {\n+                        continue;\n+                    };\n+                    let Some(item_b) = Self::normalize_item(&item2) else {\n+                        continue;\n+                    };\n+\n+                    let vote = VoteData {\n+                        ts: ing.ts,\n+                        a: item_a.clone(),\n+                        b: item_b.clone(),\n+                        ratio_left,\n+                        ratio_right,\n+                        body: explanation,\n+                        principal: principal.clone(),\n+                        delegate: delegate.clone(),\n+                        thread_tag: canonical_thread.clone(),\n+                    };\n+\n+                    ingest_items.insert(item_a.clone());\n+                    ingest_items.insert(item_b.clone());\n+\n+                    nav!(content.items, set_elem(item_a.clone()));\n+                    nav!(content.items, set_elem(item_b.clone()));\n+                    Self::add_child_edge(content, &item_a);\n+                    Self::add_child_edge(content, &item_b);\n+\n+                    content.ranking_group.apply_vote(vote.clone());\n+\n+                    for it in [&item_a, &item_b] {\n+                        nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));\n+                    }\n+                }\n+                dsl::Stmt::Prose { .. } => {}\n+            }\n+        }\n+\n+        for item in ingest_items.iter() {\n+            nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));\n+        }\n+\n+        for item in ingest_items.iter() {\n+            nav!(\n+                content.item_threads,\n+                keypath(item.clone()),\n+                set_elem(canonical_thread.clone())\n+            );\n+        }\n+\n+        if !voted_items.is_empty() {\n+            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n+            let thread = canonical_thread.clone();\n+            for item in &voted_items {\n+                let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);\n+                let after_global = Self::global_rank_of(&content.ranking_group, item);\n+                let score = content\n+                    .ranking_group\n+                    .item_to_idx\n+                    .get(item)\n+                    .and_then(|&i| content.ranking_group.cached_scores.get(i))\n+                    .copied()\n+                    .unwrap_or(0.0);\n+                let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));\n+                let prev = content.rank_history.get(item).and_then(|v| v.last());\n+                let scope_delta = if prev.is_none() {\n+                    0\n+                } else {\n+                    after_scope as i32 - before_scope as i32\n+                };\n+                let global_delta = if prev.is_none() {\n+                    0\n+                } else {\n+                    after_global as i32 - before_global as i32\n+                };\n+                let scope_total = item\n+                    .parent()\n+                    .and_then(|p| content.item_children.get(&p))\n+                    .map(|s| s.len())\n+                    .unwrap_or(0);\n+                let global_total = content.ranking_group.idx_to_item.len();\n+                content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {\n+                    ts: ing.ts,\n+                    scope_rank: after_scope,\n+                    scope_rank_delta: scope_delta,\n+                    scope_total,\n+                    global_rank: after_global,\n+                    global_rank_delta: global_delta,\n+                    global_total,\n+                    score,\n+                    thread: thread.clone(),\n+                    post_id: ing.id.clone(),\n+                });\n+            }\n+        }\n+\n+        Ok(())\n+    }\n+\n+    fn rebuild_scope_content(&mut self, scope: ScopeId) {\n+        let mut cs = ContentState::default();\n+        for id in &self.ingests_ordered {\n+            if self.redacted_posts.contains(id) {\n+                continue;\n+            }\n+            let Some(ing) = self.ingests_by_id.get(id) else {\n+                continue;\n+            };\n+            if scope_from_room_wire(&ing.room_id) != scope {\n+                continue;\n+            }\n+            let _ = Self::apply_ingest_to_content(&mut cs, ing);\n+        }\n+        self.content.insert(scope, cs);\n+        // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so\n+        // per-post URLs and chronological indices remain stable; only projected garden state resets.\n+    }\n+\n+    /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).\n+    fn purge_private_room(&mut self, room_id: &str) {\n+        let scope = ScopeId::Room(room_id.to_string());\n+        self.rooms.remove(room_id);\n+        self.grants.remove(room_id);\n+        self.room_timeline.remove(room_id);\n+        self.invites.retain(|_, inv| inv.room_id != room_id);\n+        self.content.remove(&scope);\n+        self.forum_threads.retain(|(s, _), _| s != &scope);\n+        self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);\n+\n+        let mut to_drop: Vec = self\n+            .ingests_by_id\n+            .iter()\n+            .filter(|(_, ing)| ing.room_id.trim() == room_id)\n+            .map(|(id, _)| id.clone())\n+            .collect();\n+        to_drop.sort();\n+        to_drop.dedup();\n+        for id in to_drop {\n+            if let Some(ing) = self.ingests_by_id.remove(&id) {\n+                self.posts_by_actor\n+                    .entry(ing.principal)\n+                    .and_modify(|q| {\n+                        q.retain(|x| x != &id);\n+                    });\n+            }\n+            self.ingests_ordered.retain(|x| x != &id);\n+            self.redacted_posts.remove(&id);\n+            self.post_redact_ts.remove(&id);\n+        }\n+    }\n+\n+    pub fn apply_event(&mut self, event: Event) {\n+        match event {\n+            Event::UserRegistered(ur) => {\n+                self.users_by_provider.insert(\n+                    (ur.provider.to_lowercase(), ur.provider_id.clone()),\n+                    ur.username,\n+                );\n+            }\n+            Event::TokenIssued(ti) => {\n+                self.tokens_by_id.insert(\n+                    ti.token_id.clone(),\n+                    (ti.username, ti.salt.clone(), ti.token_hash.clone()),\n+                );\n+            }\n+            Event::AgentBound(ab) => {\n+                if ab.agent.is_empty() {\n+                    return;\n+                }\n+                self.agent_bindings.insert(ab.agent, ab.username);\n+            }\n+            Event::RoomCreated(rc) => {\n+                self.rooms.insert(rc.room_id.clone());\n+                self.room_timeline\n+                    .entry(rc.room_id.clone())\n+                    .or_default()\n+                    .push(RoomTimelineEntry {\n+                        ts: rc.ts,\n+                        kind: RoomTimelineKind::RoomCreated {\n+                            owner: rc.owner.clone(),\n+                            slug: rc.slug.clone(),\n+                        },\n+                    });\n+            }\n+            Event::RoomDeleted(rd) => {\n+                let room_id = rd.room_id.clone();\n+                if self.rooms.contains(&room_id) {\n+                    self.room_timeline\n+                        .entry(room_id.clone())\n+                        .or_default()\n+                        .push(RoomTimelineEntry {\n+                            ts: rd.ts,\n+                            kind: RoomTimelineKind::RoomDeleted {\n+                                deleted_by: rd.deleted_by.clone(),\n+                            },\n+                        });\n+                }\n+                self.purge_private_room(&room_id);\n+            }\n+            Event::Ingest(mut ing) => {\n+                ing.thread_tag = canonicalize_tag(&ing.thread_tag);\n+                let room_key = ing.room_id.trim().to_string();\n+                let scope = scope_from_room_wire(&room_key);\n+                let canonical_thread = ing.thread_tag.clone();\n+                let scope_thread_key = (scope.clone(), canonical_thread.clone());\n+\n+                {\n+                    let content = self.content_for_scope_mut(scope.clone());\n+                    if Self::apply_ingest_to_content(content, &ing).is_err() {\n+                        eprintln!(\n+                            \"WARNING: Skipping malformed ingest event {}: parse failed\",\n+                            ing.id\n+                        );\n+                        return;\n+                    }\n+                }\n+\n+                self.ingests_by_id.insert(ing.id.clone(), ing.clone());\n+\n+                let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();\n+                let prev_ts = ft.last_activity_ts;\n+                if ing.ts > prev_ts {\n+                    ft.last_activity_ts = ing.ts;\n+                    ft.last_actor = ing.principal.clone();\n+                }\n+\n+                nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));\n+\n+                nav!(self.ingests_ordered, push_back(ing.id.clone()));\n+\n+                nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));\n+\n+                nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));\n+            }\n+            Event::PostRedacted(pr) => {\n+                self.redacted_posts.insert(pr.post_id.clone());\n+                self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);\n+                let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {\n+                    return;\n+                };\n+                let scope = scope_from_room_wire(ing.room_id.trim());\n+                // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.\n+                self.rebuild_scope_content(scope);\n+            }\n+            Event::GrantAdded(ga) => {\n+                let room_id = ga.room_id.clone();\n+                let caps = self.grants\n+                    .entry(ga.room_id)\n+                    .or_default()\n+                    .entry(ga.username.clone())\n+                    .or_default();\n+                for cap in ga.capabilities.iter().copied() {\n+                    caps.insert(cap);\n+                }\n+                self.room_timeline\n+                    .entry(room_id)\n+                    .or_default()\n+                    .push(RoomTimelineEntry {\n+                        ts: ga.ts,\n+                        kind: RoomTimelineKind::GrantAdded {\n+                            username: ga.username.clone(),\n+                            granted_by: ga.granted_by.clone(),\n+                            capabilities: ga.capabilities.clone(),\n+                        },\n+                    });\n+            }\n+            Event::GrantRevoked(gr) => {\n+                let room_id = gr.room_id.clone();\n+                if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {\n+                    let username = gr.username.clone();\n+                    if let Some(caps) = room_grants.get_mut(&username) {\n+                        for cap in &gr.capabilities {\n+                            caps.remove(cap);\n+                        }\n+                        if caps.is_empty() {\n+                            room_grants.remove(&username);\n+                        }\n+                    }\n+                    if room_grants.is_empty() {\n+                        self.grants.remove(&gr.room_id);\n+                    }\n+                }\n+                self.room_timeline\n+                    .entry(room_id)\n+                    .or_default()\n+                    .push(RoomTimelineEntry {\n+                        ts: gr.ts,\n+                        kind: RoomTimelineKind::GrantRevoked {\n+                            username: gr.username.clone(),\n+                            revoked_by: gr.revoked_by.clone(),\n+                            capabilities: gr.capabilities.clone(),\n+                        },\n+                    });\n+            }\n+            Event::InviteMinted(im) => {\n+                self.invites.insert(\n+                    im.token.clone(),\n+                    ActiveInviteState {\n+                        room_id: im.room_id.clone(),\n+                        capabilities: im.capabilities.iter().copied().collect(),\n+                        inviter: im.inviter.clone(),\n+                        uses_remaining: im.max_uses,\n+                        expires_ts_ms: im.expires_ts_ms,\n+                    },\n+                );\n+            }\n+            Event::InviteRedeemed(ir) => {\n+                if let Some(inv) = self.invites.get_mut(&ir.token) {\n+                    inv.uses_remaining = inv.uses_remaining.saturating_sub(1);\n+                    if inv.uses_remaining == 0 {\n+                        self.invites.remove(&ir.token);\n+                    }\n+                }\n+            }\n+        }\n+    }\n+}\n+\n+impl Default for ReducerState {\n+    fn default() -> Self {\n+        let mut content = HashMap::new();\n+        content.insert(ScopeId::Public, ContentState::default());\n+        Self {\n+            content,\n+            users_by_provider: HashMap::new(),\n+            tokens_by_id: HashMap::new(),\n+            agent_bindings: HashMap::new(),\n+            ingests_by_id: HashMap::new(),\n+            ingests_by_scope_thread: HashMap::new(),\n+            rooms: HashSet::new(),\n+            forum_threads: HashMap::new(),\n+            actor_last_post_ts: HashMap::new(),\n+            ingests_ordered: Vec::new(),\n+            posts_by_actor: HashMap::new(),\n+            redacted_posts: HashSet::new(),\n+            post_redact_ts: HashMap::new(),\n+            grants: HashMap::new(),\n+            room_timeline: HashMap::new(),\n+            invites: HashMap::new(),\n+        }\n+    }\n+}\n+\ndiff --git a/legacy/ui_action.rs b/legacy/ui_action.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5e4131dd346a6f80bfe091a9b0cf7902721bc47f\n--- /dev/null\n+++ b/legacy/ui_action.rs\n@@ -0,0 +1,270 @@\n+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).\n+//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).\n+\n+use crate::form_template::fill_template_from_form;\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+use std::collections::HashMap;\n+use thiserror::Error;\n+\n+/// Form field name for the compact JSON template (possibly with `{\"$form\":\"…\"}` holes).\n+pub const UI_RPC_FIELD: &str = \"__rpc__\";\n+\n+fn default_ui_form_action() -> String {\n+    \"/ui\".to_string()\n+}\n+\n+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[serde(tag = \"action\", rename_all = \"snake_case\")]\n+pub enum HtmlUiAction {\n+    /// Forum ingest via `POST /ui`.\n+    PostIngest {\n+        room: String,\n+        thread_tag: String,\n+        text: String,\n+        #[serde(default)]\n+        error_target: Option,\n+        #[serde(default)]\n+        form_id: Option,\n+    },\n+    /// DSL check / validation via `POST /ui`.\n+    CheckIngest {\n+        room: String,\n+        thread_tag: String,\n+        text: String,\n+        #[serde(default)]\n+        error_target: Option,\n+        #[serde(default)]\n+        form_id: Option,\n+    },\n+    /// Post a pairwise vote from `/vote` (browser compose).\n+    VoteComparePost {\n+        room: String,\n+        thread_tag: String,\n+        left_item: String,\n+        right_item: String,\n+        /// From form fields (string); parsed server-side.\n+        ratio_left: String,\n+        ratio_right: String,\n+        explanation: String,\n+        /// Same-origin path after successful post (JS redirect).\n+        next: String,\n+        /// Pool parent item path, if the vote was initiated from a pool URL.\n+        #[serde(default)]\n+        pool: Option,\n+        #[serde(default = \"default_ui_form_action\")]\n+        form_action: String,\n+    },\n+    /// Set or clear the garden HUD pin cookie (`slug_garden_pin`). Response is **`303` + `Set-Cookie`** when submitted as a full-navigation form (`data-navigate=\"full\"`), matching `POST /theme`.\n+    SetGardenPin {\n+        #[serde(default)]\n+        clear: bool,\n+        #[serde(default)]\n+        room_wire: String,\n+        #[serde(default)]\n+        item_storage: Option,\n+        next: String,\n+        /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.\n+        #[serde(default = \"default_ui_form_action\")]\n+        form_action: String,\n+    },\n+    /// Resolve children or siblings for an external garden item.\n+    ResolveExternal {\n+        room_wire: String,\n+        item_storage: String,\n+        mode: String,\n+        next: String,\n+        #[serde(default = \"default_ui_form_action\")]\n+        form_action: String,\n+    },\n+    /// Author redacts own post via `POST /ui`.\n+    RedactPost { post_id: String },\n+    /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n+    SetRoomMembersExpanded {\n+        room_wire: String,\n+        #[serde(default)]\n+        expanded: bool,\n+    },\n+    /// Delete the private room (Manage only); redirects to `/` on success.\n+    DeleteRoom { room: String },\n+    /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n+    SetNewThreadComposeExpanded {\n+        room_wire: String,\n+        #[serde(default)]\n+        expanded: bool,\n+    },\n+    /// Replace a truncated post card with the full body (same thread index).\n+    ExpandPostFull {\n+        room: String,\n+        thread_tag: String,\n+        post_index: usize,\n+    },\n+    /// Expand a redacted/tombstone post to show stripped body (author view).\n+    ExpandRedactedPost {\n+        room: String,\n+        thread_tag: String,\n+        post_index: usize,\n+    },\n+    /// Collapse an expanded redacted post back to the tombstone card.\n+    CollapseRedactedPost {\n+        room: String,\n+        thread_tag: String,\n+        post_index: usize,\n+    },\n+    /// Copy full thread text (CLI `forum show` format) to the clipboard.\n+    CopyThread {\n+        room: String,\n+        thread_tag: String,\n+        copy_btn_id: String,\n+    },\n+}\n+\n+#[derive(Debug, Error)]\n+pub enum HtmlUiParseError {\n+    #[error(\"missing __rpc__ field\")]\n+    MissingRpc,\n+    #[error(\"invalid template json: {0}\")]\n+    Template(serde_json::Error),\n+    #[error(\"invalid ui action: {0}\")]\n+    Action(serde_json::Error),\n+}\n+\n+/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.\n+pub fn parse_html_ui_from_form(\n+    form: &HashMap,\n+) -> Result {\n+    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;\n+    let mut hole_map = form.clone();\n+    hole_map.remove(UI_RPC_FIELD);\n+    let v: Value =\n+        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n+    serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+\n+    #[test]\n+    fn round_trip_post_ingest_with_holes() {\n+        let template = serde_json::json!({\n+            \"action\": \"post_ingest\",\n+            \"room\": \"public\",\n+            \"thread_tag\": {\"$form\": \"thread_tag\"},\n+            \"text\": {\"$form\": \"text\"},\n+            \"error_target\": \"e\",\n+            \"form_id\": \"f\",\n+        });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        form.insert(\"thread_tag\".into(), \"x\".into());\n+        form.insert(\"text\".into(), \"body\".into());\n+\n+        let a = parse_html_ui_from_form(&form).unwrap();\n+        assert_eq!(\n+            a,\n+            HtmlUiAction::PostIngest {\n+                room: \"public\".into(),\n+                thread_tag: \"x\".into(),\n+                text: \"body\".into(),\n+                error_target: Some(\"e\".into()),\n+                form_id: Some(\"f\".into()),\n+            }\n+        );\n+    }\n+\n+    #[test]\n+    fn expand_post_full_round_trip() {\n+        let template = serde_json::json!({\n+            \"action\": \"expand_post_full\",\n+            \"room\": \"public\",\n+            \"thread_tag\": \"demo\",\n+            \"post_index\": 3,\n+        });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        let a = parse_html_ui_from_form(&form).unwrap();\n+        assert_eq!(\n+            a,\n+            HtmlUiAction::ExpandPostFull {\n+                room: \"public\".into(),\n+                thread_tag: \"demo\".into(),\n+                post_index: 3,\n+            }\n+        );\n+    }\n+\n+    #[test]\n+    fn set_room_members_expanded_defaults_false() {\n+        let template = serde_json::json!({\n+            \"action\": \"set_room_members_expanded\",\n+            \"room_wire\": \"ab/cd\",\n+        });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        let a = parse_html_ui_from_form(&form).unwrap();\n+        assert_eq!(\n+            a,\n+            HtmlUiAction::SetRoomMembersExpanded {\n+                room_wire: \"ab/cd\".into(),\n+                expanded: false,\n+            }\n+        );\n+    }\n+\n+    #[test]\n+    fn copy_thread_round_trip() {\n+        let template = serde_json::json!({\n+            \"action\": \"copy_thread\",\n+            \"room\": \"public\",\n+            \"thread_tag\": \"demo\",\n+            \"copy_btn_id\": \"thread-copy-top\",\n+        });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        let a = parse_html_ui_from_form(&form).unwrap();\n+        assert_eq!(\n+            a,\n+            HtmlUiAction::CopyThread {\n+                room: \"public\".into(),\n+                thread_tag: \"demo\".into(),\n+                copy_btn_id: \"thread-copy-top\".into(),\n+            }\n+        );\n+    }\n+\n+    #[test]\n+    fn set_new_thread_compose_expanded_true() {\n+        let template = serde_json::json!({\n+            \"action\": \"set_new_thread_compose_expanded\",\n+            \"room_wire\": \"ab/cd\",\n+            \"expanded\": true,\n+        });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        let a = parse_html_ui_from_form(&form).unwrap();\n+        assert_eq!(\n+            a,\n+            HtmlUiAction::SetNewThreadComposeExpanded {\n+                room_wire: \"ab/cd\".into(),\n+                expanded: true,\n+            }\n+        );\n+    }\n+}\ndiff --git a/legacy/views.rs b/legacy/views.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d4f0ffc49475f014698b4da0de6f476884430813\n--- /dev/null\n+++ b/legacy/views.rs\n@@ -0,0 +1,63 @@\n+use std::{\n+    collections::HashMap,\n+    sync::{Arc, Mutex},\n+};\n+use tokio::sync::mpsc;\n+\n+type CountMap = Arc>>;\n+\n+#[derive(Clone)]\n+pub struct ViewStore {\n+    counts: CountMap,\n+    flush_tx: mpsc::Sender<()>,\n+}\n+\n+impl ViewStore {\n+    pub fn new(json_path: &str) -> Self {\n+        // Load existing counts from disk on startup (best-effort)\n+        let initial: HashMap = std::fs::read_to_string(json_path)\n+            .ok()\n+            .and_then(|s| serde_json::from_str(&s).ok())\n+            .unwrap_or_default();\n+\n+        let counts: CountMap = Arc::new(Mutex::new(initial));\n+        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);\n+        let path = json_path.to_string();\n+\n+        let counts_for_writer = counts.clone();\n+        tokio::spawn(async move {\n+            while flush_rx.recv().await.is_some() {\n+                while flush_rx.try_recv().is_ok() {}\n+\n+                let snapshot: HashMap = {\n+                    counts_for_writer.lock().unwrap().clone()\n+                };\n+\n+                let path = path.clone();\n+                let _ = tokio::task::spawn_blocking(move || {\n+                    if let Ok(json) = serde_json::to_string(&snapshot) {\n+                        let tmp = format!(\"{path}.tmp\");\n+                        if std::fs::write(&tmp, &json).is_ok() {\n+                            let _ = std::fs::rename(&tmp, &path);\n+                        }\n+                    }\n+                })\n+                .await;\n+            }\n+        });\n+\n+        Self { counts, flush_tx }\n+    }\n+\n+    pub fn increment(&self, path: String) {\n+        {\n+            let mut map = self.counts.lock().unwrap();\n+            *map.entry(path).or_insert(0) += 1;\n+        }\n+        let _ = self.flush_tx.try_send(());\n+    }\n+\n+    pub fn get_views(&self, path: &str) -> u64 {\n+        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)\n+    }\n+}\ndiff --git a/legacy/vote.rs b/legacy/vote.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d0ec78cd675eae284d056fb3b8eaf5cc853d6263\n--- /dev/null\n+++ b/legacy/vote.rs\n@@ -0,0 +1,580 @@\n+use axum::{\n+    extract::{Path, Query, State},\n+    http::{HeaderMap, StatusCode, Uri},\n+    response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::html;\n+use serde::Deserialize;\n+use serde_json::json;\n+use std::collections::{HashMap, HashSet};\n+\n+use crate::{\n+    api::optional_principal,\n+    canonical_path::canonicalize_tag,\n+    form_template::template_json_compact,\n+    html::{\n+        forum::ThreadNav,\n+        layout_full_bleed_chromeless,\n+        ratio_pct, render_item_body_in_scope,\n+        theme_from_jar, theme_next_from_uri,\n+        user_can_post_room,\n+        ui_action::UI_RPC_FIELD,\n+        JsBuilder,\n+    },\n+    middleware::canonical_view_url,\n+    path_types::ItemId,\n+    reducer::{ContentState, ScopeId},\n+    scope_rank::suggest_next_pair_in_pool,\n+    state::AppState,\n+};\n+\n+use super::{\n+    access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},\n+    item::{item_display_path, login_href_with_next},\n+};\n+\n+fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {\n+    let cands: HashSet = content\n+        .item_threads\n+        .get(a)\n+        .into_iter()\n+        .chain(content.item_threads.get(b))\n+        .flat_map(|s| s.iter().cloned())\n+        .collect();\n+    if cands.is_empty() {\n+        return \"vote\".to_string();\n+    }\n+    let mut v: Vec = cands.into_iter().collect();\n+    v.sort();\n+    canonicalize_tag(&v[0])\n+}\n+\n+/// Canonical unordered pair: lexicographic by storage string (stable edge identity).\n+pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n+    let ac = a.clone().normalized_storage();\n+    let bc = b.clone().normalized_storage();\n+    if ac.as_str() <= bc.as_str() {\n+        (ac, bc)\n+    } else {\n+        (bc, ac)\n+    }\n+}\n+\n+/// All votes whose endpoints are exactly this unordered pair (unsorted).\n+pub(super) fn edge_vote_entries_for_pair(\n+    content: &ContentState,\n+    a: &ItemId,\n+    b: &ItemId,\n+) -> 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+    content\n+        .item_votes\n+        .get(&lo)\n+        .into_iter()\n+        .flat_map(|q| q.iter())\n+        .filter(|v| {\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+}\n+\n+pub(super) fn ratios_for_compare_page(\n+    v: &crate::reducer::VoteData,\n+    page_left: &ItemId,\n+    page_right: &ItemId,\n+) -> (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+pub(super) 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+pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {\n+    let (lo, hi) = canonical_edge_items(a, b);\n+    let lo_s = lo.as_str();\n+    let hi_s = hi.as_str();\n+    content\n+        .item_votes\n+        .get(&lo)\n+        .into_iter()\n+        .flat_map(|q| q.iter())\n+        .filter(|v| {\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+        .count()\n+}\n+\n+fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {\n+    let set: HashSet = content\n+        .item_threads\n+        .get(a)\n+        .into_iter()\n+        .chain(content.item_threads.get(b))\n+        .flat_map(|s| s.iter().cloned())\n+        .collect();\n+    let mut v: Vec = set.into_iter().collect();\n+    v.sort();\n+    v.into_iter().map(|t| canonicalize_tag(&t)).collect()\n+}\n+\n+fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {\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\" {\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 (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+                            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\" 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+                        }\n+                    }\n+                }\n+            }\n+        }\n+    }\n+}\n+\n+/// After a successful vote post: refresh edge history (no in-page preview card).\n+#[allow(clippy::too_many_arguments)]\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+    pool: Option<&ItemId>,\n+    _post_id: &str,\n+    _post_idx: Option,\n+) -> String {\n+    let reduced = state.reduced.read().await;\n+    let content = content_for_garden_view(&reduced, &nav.scope());\n+    let edge_history = vote_edge_history_markup(content, left, right);\n+    let next_pair = suggest_next_vote_pair(content, left, right, pool);\n+    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);\n+    drop(reduced);\n+    JsBuilder::new()\n+        .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+        .morph_selector(\".vote-compare-nav\", nav_markup)\n+        .build()\n+}\n+\n+pub(super) fn vote_compare_href(\n+    nav: &ThreadNav,\n+    left: &ItemId,\n+    right: &ItemId,\n+    thread_override: Option<&str>,\n+    pool: Option<&ItemId>,\n+) -> String {\n+    let left_dp = left.display_path();\n+    let right_dp = right.display_path();\n+    let left_q = urlencoding::encode(&left_dp);\n+    let right_q = urlencoding::encode(&right_dp);\n+    let mut base = format!(\n+        \"{}/vote?left={}&right={}\",\n+        nav.room_path_prefix_for_vote_compare(),\n+        left_q,\n+        right_q\n+    );\n+    if let Some(t) = thread_override.filter(|s| !s.is_empty()) {\n+        base = format!(\"{}&thread={}\", base, urlencoding::encode(t));\n+    }\n+    if let Some(p) = pool {\n+        let pool_dp = p.display_path();\n+        base = format!(\"{}&pool={}\", base, urlencoding::encode(&pool_dp));\n+    }\n+    base\n+}\n+\n+pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {\n+    let display = ItemId::parse(pool_item_str)\n+        .map(|i| i.display_path())\n+        .unwrap_or_else(|| pool_item_str.to_string());\n+    format!(\n+        \"{}/vote?pool={}\",\n+        nav.room_path_prefix_for_vote_compare(),\n+        urlencoding::encode(&display)\n+    )\n+}\n+\n+fn vote_compare_nav_markup(\n+    nav: &ThreadNav,\n+    next_pair: Option<&(ItemId, ItemId)>,\n+    pool: Option<&ItemId>,\n+) -> maud::Markup {\n+    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));\n+    html! {\n+        div class=\"vote-compare-nav\" {\n+            @if let Some(href) = &next_pair_href {\n+                a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n+            } @else {\n+                span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n+            }\n+        }\n+    }\n+}\n+\n+pub(super) fn suggest_next_vote_pair(\n+    content: &ContentState,\n+    current_left: &ItemId,\n+    current_right: &ItemId,\n+    pool_parent: Option<&ItemId>,\n+) -> Option<(ItemId, ItemId)> {\n+    let pool: Vec = if let Some(parent) = pool_parent {\n+        content\n+            .item_children\n+            .get(parent)\n+            .map(|s| s.iter().cloned().collect())\n+            .unwrap_or_default()\n+    } else if current_left.parent().as_ref().map(|p| p.as_str())\n+        == current_right.parent().as_ref().map(|p| p.as_str())\n+    {\n+        current_left\n+            .parent()\n+            .and_then(|parent| {\n+                content\n+                    .item_children\n+                    .get(&parent.normalized_storage())\n+                    .cloned()\n+            })\n+            .map(|children| children.into_iter().collect())\n+            .unwrap_or_default()\n+    } else {\n+        Vec::new()\n+    };\n+    if pool.len() < 2 {\n+        return None;\n+    }\n+    suggest_next_pair_in_pool(\n+        &content.ranking_group,\n+        &pool,\n+        Some((current_left, current_right)),\n+    )\n+}\n+\n+pub(super) fn vote_compare_item_card(\n+    nav: &ThreadNav,\n+    item: &ItemId,\n+    body: Option<&String>,\n+    side_class: &str,\n+    item_bodies: Option<&HashMap>,\n+) -> maud::Markup {\n+    html! {\n+        div class=(format!(\"vote-compare-side {side_class}\")) {\n+            a class=(format!(\"vote-compare-item {side_class}\")) href=(nav.garden_item_href(item)) {\n+                code { (item_display_path(item.as_str())) }\n+            }\n+            @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n+                div class=\"vote-compare-item-body\" {\n+                    (render_item_body_in_scope(\n+                        body,\n+                        nav.garden_root_url(),\n+                        item_bodies,\n+                    ))\n+                }\n+            } @else {\n+                p class=\"muted vote-compare-item-body-empty\" { \"no body yet\" }\n+            }\n+        }\n+    }\n+}\n+#[derive(Debug, Deserialize)]\n+pub struct VoteCompareQuery {\n+    #[serde(default)]\n+    pub left: Option,\n+    #[serde(default)]\n+    pub right: Option,\n+    #[serde(default)]\n+    pub thread: Option,\n+    #[serde(default)]\n+    pub pool: Option,\n+}\n+\n+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.\n+pub async fn vote_compare_page(\n+    State(state): State,\n+    Query(q): Query,\n+    headers: HeaderMap,\n+    jar: CookieJar,\n+    uri: Uri,\n+) -> impl IntoResponse {\n+    let nav = ThreadNav::public();\n+    vote_compare_inner(state, q, nav, headers, jar, uri).await\n+}\n+\n+pub async fn room_vote_compare_page(\n+    State(state): State,\n+    Path(room_key): Path,\n+    Query(q): Query,\n+    headers: HeaderMap,\n+    jar: CookieJar,\n+    uri: Uri,\n+) -> impl IntoResponse {\n+    let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {\n+        return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+    };\n+    let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+        return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+    };\n+    let reduced = state.reduced.read().await;\n+    let user = optional_principal(&headers, &jar, &reduced);\n+    if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n+        drop(reduced);\n+        return room_not_found_page(&jar, &uri).into_response();\n+    }\n+    if !room_scope_has_garden_content(&reduced, &nav) {\n+        drop(reduced);\n+        return room_not_found_page(&jar, &uri).into_response();\n+    }\n+    drop(reduced);\n+    vote_compare_inner(state, q, nav, headers, jar, uri).await\n+}\n+\n+async fn vote_compare_inner(\n+    state: AppState,\n+    q: VoteCompareQuery,\n+    nav: ThreadNav,\n+    headers: HeaderMap,\n+    jar: CookieJar,\n+    uri: Uri,\n+) -> axum::response::Response {\n+    let pool_id: Option = match q.pool.as_deref() {\n+        Some(p) => match ItemId::parse(p.trim()) {\n+            Some(i) => Some(i.normalized_storage()),\n+            None => return (StatusCode::BAD_REQUEST, \"bad pool item\").into_response(),\n+        },\n+        None => None,\n+    };\n+\n+    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {\n+        (Some(l), Some(r)) => {\n+            let left = match ItemId::parse(l.trim()) {\n+                Some(i) => i.normalized_storage(),\n+                None => return (StatusCode::NOT_FOUND, \"bad left item\").into_response(),\n+            };\n+            let right = match ItemId::parse(r.trim()) {\n+                Some(i) => i.normalized_storage(),\n+                None => return (StatusCode::NOT_FOUND, \"bad right item\").into_response(),\n+            };\n+            if left == right {\n+                return (StatusCode::BAD_REQUEST, \"items must differ\").into_response();\n+            }\n+            (left, right)\n+        }\n+        (None, None) => {\n+            let Some(pool) = pool_id.as_ref() else {\n+                return (StatusCode::BAD_REQUEST, \"provide left+right or pool\").into_response();\n+            };\n+            let reduced = state.reduced.read().await;\n+            let content = content_for_garden_view(&reduced, &nav.scope());\n+            let children: Vec = content\n+                .item_children\n+                .get(pool)\n+                .map(|s| s.iter().cloned().collect())\n+                .unwrap_or_default();\n+            if children.len() < 2 {\n+                drop(reduced);\n+                return (StatusCode::BAD_REQUEST, \"pool has fewer than 2 children to compare\").into_response();\n+            }\n+            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);\n+            drop(reduced);\n+            match pair {\n+                Some(p) => p,\n+                None => return (StatusCode::BAD_REQUEST, \"no pairs available in pool\").into_response(),\n+            }\n+        }\n+        _ => return (StatusCode::BAD_REQUEST, \"provide both left and right, or just pool\").into_response(),\n+    };\n+\n+    let reduced = state.reduced.read().await;\n+    let content = content_for_garden_view(&reduced, &nav.scope());\n+    let viewer = optional_principal(&headers, &jar, &reduced);\n+    let can_post = match &nav.scope() {\n+        ScopeId::Public => viewer.is_some(),\n+        ScopeId::Room(rid) => viewer\n+            .as_ref()\n+            .map(|u| user_can_post_room(&reduced, rid, u))\n+            .unwrap_or(false),\n+    };\n+    let auto_thread = q\n+        .thread\n+        .as_ref()\n+        .map(|t| canonicalize_tag(t))\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);\n+    let left_body = content.item_bodies.get(&left).cloned();\n+    let right_body = content.item_bodies.get(&right).cloned();\n+    let item_bodies_for_cards = content.item_bodies.clone();\n+    let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());\n+    drop(reduced);\n+\n+    let title = format!(\n+        \"vote — {} vs {}\",\n+        item_display_path(left.as_str()),\n+        item_display_path(right.as_str())\n+    );\n+    let next_path = uri\n+        .path_and_query()\n+        .map(|pq| pq.as_str().to_string())\n+        .unwrap_or_else(|| \"/vote\".into());\n+\n+    let rpc_json = template_json_compact(&json!({\n+        \"action\": \"vote_compare_post\",\n+        \"room\": nav.room_wire,\n+        \"thread_tag\": {\"$form\": \"thread_tag\"},\n+        \"left_item\": left.as_str(),\n+        \"right_item\": right.as_str(),\n+        \"ratio_left\": {\"$form\": \"ratio_left\"},\n+        \"ratio_right\": {\"$form\": \"ratio_right\"},\n+        \"explanation\": {\"$form\": \"explanation\"},\n+        \"next\": next_path,\n+        \"pool\": pool_id.as_ref().map(|p| p.as_str()),\n+        \"form_action\": \"/ui\",\n+    }))\n+    .expect(\"vote compare rpc json\");\n+\n+    let body = html! {\n+    section class=\"vote-compare-shell\" {\n+        h2 { \"compare\" }\n+        div class=\"vote-compare-pair\" {\n+            (vote_compare_item_card(\n+                &nav,\n+                &left,\n+                left_body.as_ref(),\n+                \"vote-compare-left\",\n+                Some(&item_bodies_for_cards),\n+            ))\n+            span class=\"vote-compare-vs\" { \"vs\" }\n+            (vote_compare_item_card(\n+                &nav,\n+                &right,\n+                right_body.as_ref(),\n+                \"vote-compare-right\",\n+                Some(&item_bodies_for_cards),\n+            ))\n+        }\n+        (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))\n+        div id=\"vote-edge-history-region\" {\n+            (edge_history)\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+                div class=\"vote-thread-picker\" {\n+                    label class=\"vote-thread-picker-label\" { \"thread\" }\n+                    select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n+                        @if thread_tags.is_empty() {\n+                            option value=\"vote\" selected { \"#vote\" }\n+                        }\n+                        @for t in &thread_tags {\n+                            @if *t == auto_thread {\n+                                option value=(t) selected { \"#\" (t) }\n+                            } @else {\n+                                option value=(t) { \"#\" (t) }\n+                            }\n+                        }\n+                    }\n+                }\n+                input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n+                input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n+                label class=\"vote-compare-slider-label\" {\n+                    span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n+                    input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+                        aria-valuemin=\"0\" aria-valuemax=\"100\";\n+                    span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n+                }\n+                label class=\"vote-explain-label\" { \"reason (required)\" }\n+                textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n+                div id=\"vote-compare-errors\" {}\n+                p { button type=\"submit\" { \"post vote\" } }\n+            }\n+        } @else {\n+            p class=\"muted\" { a href=(login_href_with_next(&next_path)) { \"log in\" } \" to post this vote.\" }\n+        }\n+    }\n+    };\n+\n+    let url_key = canonical_view_url(&uri);\n+    let view_count = state.views.get_views(&url_key);\n+\n+    let page = layout_full_bleed_chromeless(\n+        &title,\n+        \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen\",\n+        body,\n+        Some(view_count),\n+        theme_from_jar(&jar),\n+        &theme_next_from_uri(&uri),\n+    );\n+    Html(page.into_string()).into_response()\n+}\n+\ndiff --git a/mod.rs b/mod.rs\ndeleted file mode 100644\nindex b1dfe895515a1ba94304c52ac4ea82ff6bb84c16..0000000000000000000000000000000000000000\n--- a/mod.rs\n+++ /dev/null\n@@ -1,929 +0,0 @@\n-use axum::{\n-    body::Body,\n-    extract::Path,\n-    http::{header, HeaderValue, StatusCode, Uri},\n-    response::{IntoResponse, Response},\n-    Form,\n-};\n-use axum_extra::extract::cookie::CookieJar;\n-use maud::{html, Markup, DOCTYPE};\n-use serde::Deserialize;\n-use std::collections::{HashMap, HashSet};\n-\n-mod auth;\n-mod breadcrumb_path;\n-mod editor;\n-mod forum;\n-mod garden;\n-pub mod routing;\n-mod search;\n-pub mod ui_action;\n-use breadcrumb_path::{ExternalOntologyPath, OntologyPath};\n-\n-pub use auth::{\n-    auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,\n-    choose_username_page,\n-};\n-pub use editor::{editor_check, editor_page};\n-pub use forum::{\n-    home, room_page, room_thread_post_view, room_thread_view, thread_feed_html,\n-    thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav,\n-};\n-\n-pub use forum::user_profile_page;\n-pub(crate) use forum::{\n-    fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,\n-    thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n-    user_can_post_room, user_can_view_room,\n-};\n-pub(crate) use garden::{\n-    encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,\n-    GARDEN_PIN_COOKIE,\n-};\n-pub use garden::{\n-    external_garden_index, external_ontology_path, garden_index, ontology_path,\n-    room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,\n-    room_vote_compare_page, vote_compare_page,\n-};\n-pub use routing::RouteContext;\n-pub use search::{search_page, search_results_fragment};\n-pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};\n-\n-/// Public profile URL path for a stored username (no `@`).\n-pub(crate) fn profile_href(username: &str) -> String {\n-    format!(\"/u/{username}\")\n-}\n-\n-/// Cookie name for UI theme (must match document.cookie migration in [`layout`]).\n-pub const SLUG_THEME_COOKIE: &str = \"slug-theme\";\n-\n-/// Normalize a requested theme id to a known stylesheet key.\n-pub fn normalize_theme(raw: &str) -> &'static str {\n-    match raw {\n-        \"retro\" => \"retro\",\n-        \"retro_craft\" => \"retro_craft\",\n-        _ => \"default\",\n-    }\n-}\n-\n-/// Resolved theme for rendering and cookie re-issue.\n-pub fn theme_from_jar(jar: &CookieJar) -> &'static str {\n-    jar.get(SLUG_THEME_COOKIE)\n-        .map(|c| normalize_theme(c.value()))\n-        .unwrap_or(\"default\")\n-}\n-\n-/// `Path` + optional `?query` for round-tripping after `POST /theme`.\n-pub fn theme_next_from_uri(uri: &Uri) -> String {\n-    uri.path_and_query()\n-        .map(|pq| pq.as_str().to_string())\n-        .filter(|s| !s.is_empty())\n-        .unwrap_or_else(|| \"/\".to_string())\n-}\n-\n-/// `Set-Cookie` for a validated theme (ASCII cookie value).\n-pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {\n-    let t = normalize_theme(theme);\n-    let s = format!(\"{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000\");\n-    HeaderValue::from_str(&s).expect(\"theme cookie must be ASCII\")\n-}\n-\n-/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme.\n-pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option {\n-    let c = jar.get(SLUG_THEME_COOKIE)?;\n-    Some(theme_cookie_header_value(c.value()))\n-}\n-\n-fn sanitize_theme_next(next: Option<&str>) -> String {\n-    let s = next.unwrap_or(\"/\").trim();\n-    if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n-        s.to_string()\n-    } else {\n-        \"/\".to_string()\n-    }\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct ThemeForm {\n-    theme: String,\n-    next: Option,\n-}\n-\n-/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch).\n-pub async fn post_theme(Form(form): Form) -> impl IntoResponse {\n-    let theme = normalize_theme(&form.theme);\n-    let next = sanitize_theme_next(form.next.as_deref());\n-    let loc =\n-        HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n-    Response::builder()\n-        .status(StatusCode::SEE_OTHER)\n-        .header(header::LOCATION, loc)\n-        .header(header::SET_COOKIE, theme_cookie_header_value(theme))\n-        .body(Body::empty())\n-        .expect(\"theme redirect response\")\n-}\n-\n-// Embed CSS and shared UI script at compile time\n-const THEME_DEFAULT_CSS: &str = include_str!(\"../../static/theme_default.css\");\n-const THEME_RETRO_CSS: &str = include_str!(\"../../static/theme_retro.css\");\n-const THEME_RETRO_CRAFT_CSS: &str = include_str!(\"../../static/theme_retro_craft.css\");\n-const SLUG_UI_JS: &str = include_str!(\"../../static/slug_ui.js\");\n-\n-pub async fn serve_static(Path(filename): Path) -> impl IntoResponse {\n-    if filename == \"slug_ui.js\" {\n-        return Response::builder()\n-            .status(StatusCode::OK)\n-            .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n-            .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n-            .body(SLUG_UI_JS.to_string())\n-            .unwrap()\n-            .into_response();\n-    }\n-\n-    let theme = filename\n-        .strip_prefix(\"theme_\")\n-        .and_then(|s| s.strip_suffix(\".css\"));\n-\n-    let css = match theme {\n-        Some(\"default\") => THEME_DEFAULT_CSS,\n-        Some(\"retro\") => THEME_RETRO_CSS,\n-        Some(\"retro_craft\") => THEME_RETRO_CRAFT_CSS,\n-        _ => return (StatusCode::NOT_FOUND, \"static file not found\").into_response(),\n-    };\n-\n-    Response::builder()\n-        .status(StatusCode::OK)\n-        .header(header::CONTENT_TYPE, \"text/css; charset=utf-8\")\n-        .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n-        .body(css.to_string())\n-        .unwrap()\n-        .into_response()\n-}\n-\n-pub(crate) fn js_string_literal(s: &str) -> String {\n-    serde_json::to_string(s).expect(\"javascript string escaping\")\n-}\n-\n-/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures.\n-pub(crate) fn ui_js_warn(msg: &str) -> Response {\n-    let js = format!(\"console.warn({});\", js_string_literal(msg));\n-    Response::builder()\n-        .status(StatusCode::OK)\n-        .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n-        .body(Body::from(js))\n-        .unwrap()\n-}\n-\n-pub(crate) struct JsBuilder {\n-    snippets: Vec,\n-}\n-\n-pub(crate) struct JsQueryBuilder {\n-    builder: JsBuilder,\n-    expr: String,\n-}\n-\n-impl JsBuilder {\n-    pub(crate) fn new() -> Self {\n-        Self {\n-            snippets: Vec::new(),\n-        }\n-    }\n-\n-    pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self {\n-        self.morph_expr(\n-            &format!(\"document.querySelector({})\", js_string_literal(selector)),\n-            markup,\n-            None,\n-        )\n-    }\n-\n-    /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`.\n-    pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self {\n-        self.qs(selector).morph_inner(markup)\n-    }\n-\n-    pub(crate) fn morph_expr(\n-        mut self,\n-        expr: &str,\n-        markup: Markup,\n-        morph_style: Option<&str>,\n-    ) -> Self {\n-        let html = js_string_literal(&markup.into_string());\n-        let opts = morph_style\n-            .map(|style| format!(\", {{morphStyle: {}}}\", js_string_literal(style)))\n-            .unwrap_or_default();\n-        self.snippets.push(format!(\n-            \"var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}\",\n-        ));\n-        self\n-    }\n-\n-    pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder {\n-        JsQueryBuilder {\n-            builder: self,\n-            expr: format!(\"document.querySelector({})\", js_string_literal(selector)),\n-        }\n-    }\n-\n-    pub(crate) fn id(self, id: &str) -> JsQueryBuilder {\n-        self.qs(&format!(\"#{id}\"))\n-    }\n-\n-    pub(crate) fn if_current_path_matches(\n-        mut self,\n-        path: &str,\n-        f: impl FnOnce(JsBuilder) -> JsBuilder,\n-    ) -> Self {\n-        let inner = f(JsBuilder::new()).build();\n-        self.snippets.push(format!(\n-            \"var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}\",\n-            path = js_string_literal(path),\n-        ));\n-        self\n-    }\n-\n-    pub(crate) fn if_current_path_not_matches(\n-        mut self,\n-        path: &str,\n-        f: impl FnOnce(JsBuilder) -> JsBuilder,\n-    ) -> Self {\n-        let inner = f(JsBuilder::new()).build();\n-        self.snippets.push(format!(\n-            \"var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}\",\n-            path = js_string_literal(path),\n-        ));\n-        self\n-    }\n-\n-    pub(crate) fn redirect(mut self, to: &str) -> Self {\n-        self.snippets\n-            .push(format!(\"window.location = {};\", js_string_literal(to)));\n-        self\n-    }\n-\n-    pub(crate) fn clipboard_write_text_and_label_btn(\n-        mut self,\n-        text: &str,\n-        btn_id: &str,\n-        copied_label: &str,\n-    ) -> Self {\n-        self.snippets.push(format!(\n-            \"navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});\",\n-            text = js_string_literal(text),\n-            btn_id = js_string_literal(btn_id),\n-            label = js_string_literal(copied_label),\n-        ));\n-        self\n-    }\n-\n-    /// Focus first matching element (e.g. after morphing open a compose form).\n-    pub(crate) fn focus_selector(mut self, selector: &str) -> Self {\n-        self.snippets.push(format!(\n-            \"var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}\",\n-            js_string_literal(selector),\n-        ));\n-        self\n-    }\n-\n-    pub(crate) fn build(self) -> String {\n-        self.snippets.join(\" \")\n-    }\n-\n-    pub(crate) fn into_response(self) -> Response {\n-        Response::builder()\n-            .status(StatusCode::OK)\n-            .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n-            .body(Body::from(self.build()))\n-            .unwrap()\n-    }\n-}\n-\n-impl JsQueryBuilder {\n-    pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder {\n-        let html = js_string_literal(&markup.into_string());\n-        self.builder.snippets.push(format!(\n-            \"var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}\",\n-            expr = self.expr,\n-        ));\n-        self.builder\n-    }\n-\n-    pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder {\n-        let html = js_string_literal(&markup.into_string());\n-        self.builder.snippets.push(format!(\n-            \"var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}\",\n-            expr = self.expr,\n-        ));\n-        self.builder\n-    }\n-\n-    pub(crate) fn reset(mut self) -> JsBuilder {\n-        self.builder.snippets.push(format!(\n-            \"var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}\",\n-            expr = self.expr,\n-        ));\n-        self.builder\n-    }\n-}\n-\n-#[allow(clippy::too_many_arguments)]\n-pub(super) fn layout(\n-    title: &str,\n-    view: &str,\n-    body: Markup,\n-    views: Option,\n-    theme: &str,\n-    theme_next: &str,\n-    garden_room_wire: Option<&str>,\n-    garden_path_prefix: Option<&str>,\n-) -> Markup {\n-    layout_embed_controls(\n-        title,\n-        view,\n-        body,\n-        views,\n-        theme,\n-        theme_next,\n-        garden_room_wire,\n-        garden_path_prefix,\n-        true,\n-    )\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-    views: Option,\n-    theme: &str,\n-    theme_next: &str,\n-) -> Markup {\n-    layout_embed_controls(\n-        title, view, body, views, theme, theme_next, None, None, false,\n-    )\n-}\n-\n-#[allow(clippy::too_many_arguments)]\n-fn layout_embed_controls(\n-    title: &str,\n-    view: &str,\n-    body: Markup,\n-    views: Option,\n-    theme: &str,\n-    theme_next: &str,\n-    garden_room_wire: Option<&str>,\n-    garden_path_prefix: Option<&str>,\n-    show_controls: bool,\n-) -> Markup {\n-    let theme = normalize_theme(theme);\n-    let css_href = format!(\"/static/theme_{theme}.css\");\n-    html! {\n-        (DOCTYPE)\n-        html {\n-            head {\n-                meta charset=\"utf-8\";\n-                meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n-                title { (title) }\n-                link rel=\"stylesheet\" href=(css_href) id=\"theme-stylesheet\";\n-                script src=\"https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js\" {}\n-            }\n-            body class=(view)\n-                data-garden-room=(garden_room_wire.unwrap_or(\"\"))\n-                data-garden-prefix=(garden_path_prefix.unwrap_or(\"\")) {\n-                @if let Some(n) = views {\n-                    span class=\"view-meta muted\" { (n) \" views\" }\n-                }\n-                div id=\"errors\" {}\n-                (body)\n-                @if show_controls {\n-                div id=\"controls\" {\n-                    a href=\"https://github.com/sortersocial/slug\" id=\"src-link\" { \"src\" }\n-                    div id=\"spread-control\" {\n-                        span { \"spread\" }\n-                        input type=\"range\" id=\"spread-slider\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\";\n-                    }\n-                    @if let (Some(gr), Some(ref gpx)) = (garden_room_wire, garden_path_prefix) {\n-                        @if !gr.is_empty() {\n-                            div id=\"slug-pin-hud\" class=\"slug-pin-hud\" data-garden-prefix=(gpx) {}\n-                        }\n-                    }\n-                    a id=\"search-btn\" href=\"/search\" { \"search\" }\n-                    form id=\"slug-theme-form\" method=\"post\" action=\"/theme\" {\n-                        input type=\"hidden\" name=\"next\" value=(theme_next);\n-                        select id=\"theme-select\" name=\"theme\" onchange=\"this.form.submit()\" aria-label=\"Theme\" {\n-                            @for (val, label) in [(\"default\", \"default\"), (\"retro\", \"retro\"), (\"retro_craft\", \"craft\")] {\n-                                @if theme == val {\n-                                    option value=(val) selected { (label) }\n-                                } @else {\n-                                    option value=(val) { (label) }\n-                                }\n-                            }\n-                        }\n-                    }\n-                }\n-                }\n-                script src=\"/static/slug_ui.js\" {}\n-            }\n-        }\n-    }\n-}\n-\n-pub(super) fn now_ms() -> i64 {\n-    let t = std::time::SystemTime::now()\n-        .duration_since(std::time::UNIX_EPOCH)\n-        .unwrap_or_default();\n-    t.as_millis() as i64\n-}\n-\n-pub(super) fn ratio_pct(left: i32, right: i32) -> f64 {\n-    let l = (left.max(0)) as f64;\n-    let r = (right.max(0)) as f64;\n-    let denom = l + r;\n-    if denom <= 0.0 {\n-        return 50.0;\n-    }\n-    (l / denom) * 100.0\n-}\n-\n-/// Render a single breadcrumb segment with `/` separator.\n-pub(super) fn bc_segment(label: &str, href: &str, is_current: bool) -> Markup {\n-    html! {\n-        span class=\"bc-sep\" { \" / \" }\n-        @if is_current {\n-            a href=(href) class=\"bc-current\" { (label) }\n-        } @else {\n-            a href=(href) { (label) }\n-        }\n-    }\n-}\n-\n-/// Breadcrumb for external ontology `/-/https://…`\n-fn bc_path_external(path: &ExternalOntologyPath) -> Markup {\n-    html! {\n-        a href=\"/\" { \"slug.social\" }\n-        (bc_segment(\"-\", \"/-\", path.is_root()))\n-        @for (i, id) in path.breadcrumb_chain().iter().enumerate() {\n-            @let disp = id.display_path();\n-            @let tail = disp.strip_prefix(\"-/\").unwrap_or(disp.as_str());\n-            @let href = format!(\"/-/{}\", tail);\n-            @let is_last = i + 1 == path.breadcrumb_chain().len();\n-            (bc_segment(id.last_segment(), &href, is_last))\n-        }\n-    }\n-}\n-\n-/// Breadcrumb for any ontology path segment, e.g. \"parables\" or \"parables/counting-the-cost\".\n-fn bc_path(path: &OntologyPath) -> Markup {\n-    html! {\n-        a href=(path.slug_root_href()) { \"slug.social\" }\n-        (bc_segment(\"~\", \"/~\", path.is_root()))\n-        @for (i, seg) in path.segments().iter().enumerate() {\n-            @let href = format!(\"/~/{}\", path.segments()[..=i].join(\"/\"));\n-            @let is_last = i == path.segments().len() - 1;\n-            (bc_segment(seg, &href, is_last))\n-        }\n-    }\n-}\n-\n-/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post.\n-/// Root link toggles to `/~` only at thread-root (`/`).\n-pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup {\n-    let root_href = if thread_tag.is_some() { \"/\" } else { \"/~\" };\n-    let root_is_current = thread_tag.is_none();\n-    html! {\n-        @if root_is_current {\n-            a href=(root_href) class=\"bc-current\" { \"slug.social\" }\n-        } @else {\n-            a href=(root_href) { \"slug.social\" }\n-        }\n-        @if let Some(tag) = thread_tag {\n-            @if let Some(idx) = focused_post {\n-                (bc_segment(&format!(\"#{tag}\"), &format!(\"/t/{tag}\"), false))\n-                (bc_segment(&format!(\"post #{idx}\"), &format!(\"/t/{tag}/{idx}\"), true))\n-            } @else {\n-                (bc_segment(&format!(\"#{tag}\"), &format!(\"/t/{tag}\"), true))\n-            }\n-        }\n-    }\n-}\n-\n-/// Short display label for a stored agent id (`uuid:rig:model`, no `@`).\n-pub(super) fn actor_label(agent_naked: &str) -> String {\n-    let a = agent_naked.trim();\n-    let parts: Vec<&str> = a.split(':').collect();\n-    if parts.len() >= 3 {\n-        let rig = parts[1].trim();\n-        let model = parts[2].trim();\n-        // X actors: show @handle instead of uuid hash.\n-        if rig == \"x.com\" {\n-            return format!(\"@{model}\");\n-        }\n-        let uuid = parts[0].trim();\n-        let uuid8 = uuid.chars().take(8).collect::();\n-        if !uuid8.is_empty() && !rig.is_empty() && !model.is_empty() {\n-            return format!(\"{uuid8}:{rig}:{model}\");\n-        }\n-    }\n-    a.to_string()\n-}\n-\n-/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present.\n-pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String {\n-    match delegate {\n-        Some(d) => format!(\"@@{}\", actor_label(d)),\n-        None => format!(\"@{principal}\"),\n-    }\n-}\n-\n-/// Escape HTML special chars for safe injection.\n-fn escape_html(s: &str) -> String {\n-    let mut out = String::with_capacity(s.len());\n-    for c in s.chars() {\n-        match c {\n-            '&' => out.push_str(\"&\"),\n-            '<' => out.push_str(\"<\"),\n-            '>' => out.push_str(\">\"),\n-            '\"' => out.push_str(\""\"),\n-            _ => out.push(c),\n-        }\n-    }\n-    out\n-}\n-\n-/// Max characters from an item body placed in a `title` tooltip (native hover).\n-const ITEM_LINK_TITLE_MAX_CHARS: usize = 500;\n-\n-fn collapse_whitespace_for_title(s: &str) -> String {\n-    let mut out = String::with_capacity(s.len().min(ITEM_LINK_TITLE_MAX_CHARS + 8));\n-    let mut last_was_space = true;\n-    for c in s.chars() {\n-        if c.is_whitespace() {\n-            if !last_was_space {\n-                out.push(' ');\n-                last_was_space = true;\n-            }\n-        } else {\n-            out.push(c);\n-            last_was_space = false;\n-        }\n-    }\n-    out.trim().to_string()\n-}\n-\n-fn item_body_title_snippet(body: &str) -> Option {\n-    let s = collapse_whitespace_for_title(body);\n-    if s.is_empty() {\n-        return None;\n-    }\n-    let truncated: String = s.chars().take(ITEM_LINK_TITLE_MAX_CHARS).collect();\n-    let ellipsis = if s.chars().count() > ITEM_LINK_TITLE_MAX_CHARS {\n-        \"…\"\n-    } else {\n-        \"\"\n-    };\n-    Some(format!(\"{truncated}{ellipsis}\"))\n-}\n-\n-fn garden_href_for_item_ref(\n-    raw_ref: &str,\n-    garden_prefix: &str,\n-) -> Option<(crate::path_types::ItemId, String)> {\n-    let key = slug_types::canonicalize_item(raw_ref);\n-    let id = crate::path_types::ItemId::parse(&key)?;\n-    let href = if let Some(tail) = id.tilde_tail() {\n-        if tail.is_empty() {\n-            garden_prefix.trim_end_matches('/').to_string()\n-        } else {\n-            format!(\"{}/{}\", garden_prefix.trim_end_matches('/'), tail)\n-        }\n-    } else if id.as_str().starts_with(\"https://\") || id.as_str().starts_with(\"http://\") {\n-        let display = id.display_path();\n-        let rest = display.strip_prefix(\"-/\").unwrap_or(display.as_str());\n-        let ext_prefix = format!(\"{}-\", garden_prefix.trim_end_matches('~'));\n-        format!(\"{ext_prefix}/{rest}\")\n-    } else {\n-        return None;\n-    };\n-    Some((id, href))\n-}\n-\n-fn push_item_ref_anchor(\n-    out: &mut String,\n-    raw_ref: &str,\n-    garden_prefix: &str,\n-    item_bodies: Option<&HashMap>,\n-) -> bool {\n-    let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else {\n-        return false;\n-    };\n-    out.push_str(r#\"');\n-    out.push_str(&escape_html(raw_ref));\n-    out.push_str(\"\");\n-    true\n-}\n-\n-/// Replace item refs in raw prose with clickable garden links.\n-///\n-/// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated\n-/// body preview for native browser tooltips (forum posts, item pages).\n-pub(super) fn linkify_slugs_with_prefix(\n-    raw: &str,\n-    garden_prefix: &str,\n-    item_bodies: Option<&HashMap>,\n-) -> String {\n-    let mut out = String::with_capacity(raw.len() + 64);\n-    for token in crate::dsl::tokenize_prose_item_refs(raw) {\n-        match token {\n-            crate::dsl::ProseToken::Text(text) => out.push_str(&escape_html(&text)),\n-            crate::dsl::ProseToken::ItemRef(raw_ref) => {\n-                if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) {\n-                    out.push_str(&escape_html(&raw_ref));\n-                }\n-            }\n-        }\n-    }\n-    out\n-}\n-\n-#[derive(Clone)]\n-struct EmbedFrame {\n-    src: String,\n-    title: String,\n-    provider_class: &'static str,\n-}\n-\n-fn clean_media_id(s: &str) -> String {\n-    s.chars()\n-        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')\n-        .collect()\n-}\n-\n-fn query_param(url: &str, name: &str) -> Option {\n-    let q = url.split_once('?')?.1;\n-    for pair in q.split('&') {\n-        let (k, v) = pair.split_once('=').unwrap_or((pair, \"\"));\n-        if k == name {\n-            return Some(v.to_string());\n-        }\n-    }\n-    None\n-}\n-\n-fn spotify_embed_src(url: &str) -> Option {\n-    let rest = url\n-        .strip_prefix(\"https://\")\n-        .or_else(|| url.strip_prefix(\"http://\"))?;\n-    let (host, tail) = rest.split_once('/').unwrap_or((rest, \"\"));\n-    let host = host.to_lowercase();\n-    if !(host == \"open.spotify.com\" || host == \"www.open.spotify.com\") {\n-        return None;\n-    }\n-    let path = tail\n-        .split('#')\n-        .next()\n-        .unwrap_or(tail)\n-        .split('?')\n-        .next()\n-        .unwrap_or(tail);\n-    let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();\n-    if segs.first().is_some_and(|s| s.starts_with(\"intl-\")) {\n-        segs.remove(0);\n-    }\n-    if segs.len() < 2 {\n-        return None;\n-    }\n-    let kind = segs[0];\n-    let id = clean_media_id(segs[1]);\n-    if id.is_empty() {\n-        return None;\n-    }\n-    let allowed = matches!(kind, \"track\" | \"album\" | \"playlist\" | \"episode\" | \"show\");\n-    if !allowed {\n-        return None;\n-    }\n-    Some(EmbedFrame {\n-        src: format!(\"https://open.spotify.com/embed/{kind}/{id}\"),\n-        title: format!(\"Spotify {kind}\"),\n-        provider_class: \"embed-spotify\",\n-    })\n-}\n-\n-fn youtube_embed_src(url: &str) -> Option {\n-    let rest = url\n-        .strip_prefix(\"https://\")\n-        .or_else(|| url.strip_prefix(\"http://\"))?;\n-    let (host, tail) = rest.split_once('/').unwrap_or((rest, \"\"));\n-    let host = host.to_lowercase();\n-\n-    let video_id = if host == \"youtu.be\" || host == \"www.youtu.be\" {\n-        clean_media_id(\n-            tail.split(['?', '#'])\n-                .next()\n-                .unwrap_or(tail)\n-                .trim_matches('/'),\n-        )\n-    } else if matches!(\n-        host.as_str(),\n-        \"youtube.com\" | \"www.youtube.com\" | \"m.youtube.com\" | \"music.youtube.com\"\n-    ) {\n-        let path = format!(\"/{}\", tail.split('#').next().unwrap_or(tail));\n-        if path.starts_with(\"/watch\") {\n-            clean_media_id(&query_param(url, \"v\")?)\n-        } else if let Some(id) = path.strip_prefix(\"/shorts/\") {\n-            clean_media_id(id.split(['?', '/']).next().unwrap_or(id))\n-        } else if let Some(id) = path.strip_prefix(\"/embed/\") {\n-            clean_media_id(id.split(['?', '/']).next().unwrap_or(id))\n-        } else {\n-            String::new()\n-        }\n-    } else {\n-        String::new()\n-    };\n-\n-    if video_id.is_empty() {\n-        return None;\n-    }\n-    Some(EmbedFrame {\n-        src: format!(\"https://www.youtube.com/embed/{video_id}\"),\n-        title: \"YouTube video\".to_string(),\n-        provider_class: \"embed-youtube\",\n-    })\n-}\n-\n-fn extract_embed_frames(raw: &str) -> Vec {\n-    let mut out = Vec::new();\n-    let mut seen = HashSet::new();\n-    for token in raw.split_whitespace() {\n-        let token = token\n-            .trim_matches(|c: char| c == '(' || c == '[' || c == '{' || c == '\"' || c == '\\'')\n-            .trim_end_matches(|c: char| \".,;:!?)]}\\\"'\".contains(c));\n-        if !(token.starts_with(\"https://\") || token.starts_with(\"http://\")) {\n-            continue;\n-        }\n-        let embed = spotify_embed_src(token).or_else(|| youtube_embed_src(token));\n-        if let Some(frame) = embed {\n-            if seen.insert(frame.src.clone()) {\n-                out.push(frame);\n-            }\n-        }\n-    }\n-    out\n-}\n-\n-pub(super) fn render_linkified_with_embeds_in_scope(\n-    raw: &str,\n-    garden_prefix: &str,\n-    item_bodies: Option<&HashMap>,\n-) -> Markup {\n-    let embeds = extract_embed_frames(raw);\n-    html! {\n-        pre { (maud::PreEscaped(linkify_slugs_with_prefix(raw, garden_prefix, item_bodies))) }\n-        @if !embeds.is_empty() {\n-            div class=\"rich-embeds\" {\n-                @for e in embeds {\n-                    div class=(format!(\"rich-embed {}\", e.provider_class)) {\n-                        iframe\n-                            src=(e.src)\n-                            title=(e.title)\n-                            loading=\"lazy\"\n-                            allow=\"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share\"\n-                            referrerpolicy=\"strict-origin-when-cross-origin\"\n-                            allowfullscreen {}\n-                    }\n-                }\n-            }\n-        }\n-    }\n-}\n-\n-/// Item page / thread body: resolver-specific rich HTML, else linkified `
` + media embeds.\n-pub(super) fn render_item_body_in_scope(\n-    raw: &str,\n-    garden_prefix: &str,\n-    item_bodies: Option<&HashMap>,\n-) -> Markup {\n-    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {\n-        return html! {\n-            div class=\"item-body-rich\" { (m) }\n-        };\n-    }\n-    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)\n-}\n-\n-/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.\n-fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {\n-    assert!(\n-        !s.contains('\\\\') && !s.contains('\\'') && !s.contains('\\n') && !s.contains('\\r'),\n-        \"cli_panel cmd must not contain `\\\\`, `'`, or newlines (got {s:?})\"\n-    );\n-}\n-\n-/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.\n-pub(super) fn cli_panel>(cmds: &[I]) -> Markup {\n-    if cmds.is_empty() {\n-        return html! {};\n-    }\n-    for cmd in cmds {\n-        assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());\n-    }\n-    html! {\n-        div class=\"cli-panel\" {\n-            span class=\"cli-panel-label muted\" { \"cli\" }\n-            div class=\"cli-panel-cmds\" {\n-                @for cmd in cmds {\n-                    @let s = cmd.as_ref();\n-                    button type=\"button\" class=\"cli-panel-row\" title=\"Copy command\" onclick=(format!(\n-                        r#\"navigator.clipboard.writeText('{}');\"#,\n-                        s\n-                    )) {\n-                        code class=\"cli-panel-cmd\" { (s) }\n-                    }\n-                }\n-            }\n-        }\n-    }\n-}\n-\n-/// Age bucket for recency coloring of thread entries.\n-pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {\n-    let age_ms = now_ms.saturating_sub(ts_ms);\n-    let age_secs = age_ms / 1000;\n-    if age_secs < 3600 {\n-        \"age-fresh\" // < 1 hour\n-    } else if age_secs < 86400 {\n-        \"age-recent\" // < 1 day\n-    } else if age_secs < 86400 * 7 {\n-        \"age-week\" // < 1 week\n-    } else {\n-        \"age-old\" // >= 1 week\n-    }\n-}\n-\n-#[cfg(test)]\n-mod linkify_title_tests {\n-    use super::*;\n-    use crate::path_types::ItemId;\n-    use std::collections::HashMap;\n-\n-    #[test]\n-    fn tilde_link_gets_title_from_item_bodies() {\n-        let mut bodies = HashMap::new();\n-        let key = ItemId::parse(&slug_types::canonicalize_item(\"~/foo/bar\")).unwrap();\n-        bodies.insert(key, \"Hello  world\\nline\".to_string());\n-        let html = linkify_slugs_with_prefix(\"see ~/foo/bar ok\", \"/r/x/~\", Some(&bodies));\n-        assert!(html.contains(\"title=\\\"Hello world line\\\"\"));\n-        assert!(html.contains(\"href=\\\"/r/x/~/foo/bar\\\"\"));\n-    }\n-\n-    #[test]\n-    fn raw_url_links_to_public_external_garden_page() {\n-        let html = linkify_slugs_with_prefix(\"see https://example.com/z.\", \"/~\", None);\n-        assert!(html.contains(\n-            r#\"https://example.com/z.\"#\n-        ));\n-    }\n-\n-    #[test]\n-    fn dash_ref_links_to_room_external_garden_page_with_title() {\n-        let mut bodies = HashMap::new();\n-        let key = ItemId::parse(&slug_types::canonicalize_item(\"-/example.com/z\")).unwrap();\n-        bodies.insert(key, \"External body\\npreview\".to_string());\n-        let html =\n-            linkify_slugs_with_prefix(\"see -/example.com/z\", \"/r/9ab12cdroom/~\", Some(&bodies));\n-        assert!(html.contains(r#\"href=\"/r/9ab12cdroom/-/https://example.com/z\"\"#));\n-        assert!(html.contains(r#\"title=\"External body preview\"\"#));\n-    }\n-\n-    #[test]\n-    fn code_fence_urls_are_not_linkified() {\n-        let html = linkify_slugs_with_prefix(\n-            \"```json\\n{\\\"url\\\":\\\"https://example.com/z\\\"}\\n```\\nthen https://example.com/a\",\n-            \"/~\",\n-            None,\n-        );\n-        assert!(!html.contains(r#\"href=\"/-/https://example.com/z\"\"#));\n-        assert!(html.contains(r#\"href=\"/-/https://example.com/a\"\"#));\n-    }\n-\n-    #[test]\n-    fn no_title_when_body_missing_or_empty() {\n-        let html = linkify_slugs_with_prefix(\"x ~/a/b y\", \"/~\", Some(&HashMap::new()));\n-        assert!(!html.contains(\" title=\"));\n-    }\n-}\ndiff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl\ndeleted file mode 100644\nindex 0f2231b94936c17610adf653ca26b0faa6f2ac3a..0000000000000000000000000000000000000000\n--- a/pagerank_streaming.tdsl\n+++ /dev/null\n@@ -1,11 +0,0 @@\n-eventually i want to have ranking histories.\n-like \"visionary\" and \"fraud\" waxing and waning in a uplot graph over time for #elon-musk. \n-there are too many query combinations to precomupte the ranking histories \n-(arbitrary user filters, and tag overlap combinations maybe), \n-so we're just going to have to calculate them all on demand. \n-computers are fast, its okay. for each vote, we need to calculate rank centrality again. \n-i was thinking, for n votes we calculate the rank centrality, \n-and get node weights. we then output that data to the client (over websocket, or testable barrier). \n-then we calculate n+1 votes, _but we keep the node weights in memory_ \n-so the rank centrality process converges faster. \n-this also has the side effect of making the ranking stream in satisfyingly as you load the page.\ndiff --git a/parser.rs b/parser.rs\ndeleted file mode 100644\nindex 2b87b974f8d1dd93bee35681d87668a94e4ef349..0000000000000000000000000000000000000000\n--- a/parser.rs\n+++ /dev/null\n@@ -1,1808 +0,0 @@\n-use std::collections::HashMap;\n-use std::rc::Rc;\n-use std::cell::RefCell;\n-use crate::ui::action::UIAction;\n-use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};\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-    }\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\n-type Handler = Box) -> UIAction>;\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\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-        let root_node = Rc::new(RefCell::new(Node {\n-            id: \"root\",\n-            edges: Vec::new(),\n-            handler: None,\n-        }));\n-        nodes.insert(\"root\", root_node);\n-        \n-        GraphBuilder {\n-            nodes,\n-            current_node: Some(\"root\"),\n-            root: \"root\",\n-        }\n-    }\n-    \n-    /// Select a node to add edges to\n-    pub fn at(mut self, node_id: NodeId) -> Self {\n-        // Create node if it doesn't exist\n-        if !self.nodes.contains_key(node_id) {\n-            let node = Rc::new(RefCell::new(Node {\n-                id: node_id,\n-                edges: Vec::new(),\n-                handler: None,\n-            }));\n-            self.nodes.insert(node_id, node);\n-        }\n-        self.current_node = Some(node_id);\n-        self\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(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {\n-        let current = self.current_node.expect(\"No current node selected\");\n-        \n-        // Create target node if it doesn't exist\n-        if !self.nodes.contains_key(target) {\n-            let node = Rc::new(RefCell::new(Node {\n-                id: target,\n-                edges: Vec::new(),\n-                handler: None,\n-            }));\n-            self.nodes.insert(target, node);\n-        }\n-        \n-        // Add edge to current node\n-        if let Some(node) = self.nodes.get(current) {\n-            node.borrow_mut().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(self, handler: F) -> Self \n-    where \n-        F: Fn(&str, &str, &HashMap) -> UIAction + 'static\n-    {\n-        let current = self.current_node.expect(\"No current node selected\");\n-        if let Some(node) = self.nodes.get(current) {\n-            node.borrow_mut().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-}\n-\n-// --- Parser Implementation ---\n-\n-impl Graph {\n-    pub fn parse(&self, input: &str) -> UIAction {\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) -> UIAction {\n-        let node = self.nodes.get(state.current_node_id)\n-            .expect(\"Node not found in graph\");\n-        let node_ref = node.borrow();\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_ref.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_ref, state);\n-        }\n-        \n-        let remaining = &state.input[state.cursor..];\n-        \n-        // Try to match each edge\n-        for edge in &node_ref.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 UIAction::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, UIAction::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_ref, state)\n-    }\n-    \n-    fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {\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 UIAction::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 UIAction::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-        UIAction::error(\n-            \"InvalidPath\".to_string(),\n-            format!(\"'{}' doesn't match any known pattern\", state.original_query)\n-        )\n-    }\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-}\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-    }\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-                UIAction::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-                UIAction::multiple(vec![\n-                    UIAction::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-                    UIAction::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-                UIAction::multiple(vec![\n-                    UIAction::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-                    UIAction::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-                UIAction::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-                UIAction::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-                UIAction::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-                UIAction::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-                UIAction::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-                UIAction::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-                UIAction::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-                UIAction::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-/// Parse a query string and return a UI action\n-pub fn parse_reddit_url(query: &str) -> UIAction {\n-    let graph = build_reddit_graph();\n-    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: &UIAction) -> bool {\n-            match (self, action) {\n-                (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {\n-                    data.suggestion.as_ref()\n-                        .map(|s| s.completion == *completion)\n-                        .unwrap_or(false)\n-                }\n-                (ExpectedAction::ScrollingSuggestions { completions }, UIAction::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 }, UIAction::ShowStaticGuide { title, .. }) => {\n-                    title.contains(title_contains)\n-                }\n-                (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n-                 UIAction::RenderEntityView { ns, pk }) => {\n-                    ns == \"reddit.subreddit\" && pk == subreddit\n-                }\n-                (ExpectedAction::RenderSubredditComments { subreddit }, \n-                 UIAction::RenderEntityView { ns, pk }) => {\n-                    ns == \"reddit.subreddit\" && pk == subreddit\n-                }\n-                (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {\n-                    ns == \"reddit.user\" && pk == username\n-                }\n-                (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n-                 UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n-                    s == subreddit && p == prefix\n-                }\n-                (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {\n-                    data.error_type == *error_type\n-                }\n-                (ExpectedAction::Multiple { expected_actions }, UIAction::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, UIAction::ShowMultiple { .. }) => true,\n-                (ExpectedAction::DbSuggestions { partial, prefix }, \n-                 UIAction::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, UIAction)> {\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 UIAction::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 UIAction::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, UIAction::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, UIAction::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, UIAction::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, UIAction::ShowMultiple { .. })\n-                }),\n-                Box::new(|text, action| {\n-                    // \"r/rust\" should resolve the subreddit\n-                    text == \"r/rust\" && matches!(action, UIAction::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-                    UIAction::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-                    UIAction::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, UIAction::RenderEntityView { ns, pk } \n-                        if ns == \"reddit.subreddit\" && pk == \"programming\")\n-                }),\n-            ],\n-        );\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, UIAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n-                }),\n-            ],\n-        );\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, UIAction::ShowMultiple { .. })\n-                }),\n-                Box::new(|text, action| {\n-                    text == \"r/technology\" && \n-                    matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n-                }),\n-            ],\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, UIAction::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, UIAction::ShowMultiple { .. })\n-                }),\n-            ],\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, UIAction::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, UIAction::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, UIAction::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, UIAction::ShowSuggestions(_))\n-                }),\n-                Box::new(|text, action| {\n-                    // \"https://reddit.com/\" should show guide\n-                    text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n-                }),\n-                Box::new(|text, action| {\n-                    // \"https://reddit.com/r/\" should show subreddit selection\n-                    text == \"https://reddit.com/r/\" && matches!(action, UIAction::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, UIAction::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, UIAction::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, UIAction::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-            UIAction::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-    }\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-            UIAction::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-                UIAction::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-                UIAction::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-            UIAction::ShowMultiple { actions } => {\n-                let has_guide = actions.iter()\n-                    .any(|a| matches!(a, UIAction::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-            UIAction::ShowMultiple { actions } => {\n-                let has_scrolling_suggestions = actions.iter()\n-                    .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));\n-                let has_db_suggestions = actions.iter()\n-                    .any(|a| matches!(a, UIAction::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-            UIAction::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-            UIAction::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-    }\n-}\ndiff --git a/parser.tdsl b/parser.tdsl\ndeleted file mode 100644\nindex 03ba9563ea8c73b7411cd1aa42dda9c7c313cd39..0000000000000000000000000000000000000000\n--- a/parser.tdsl\n+++ /dev/null\n@@ -1,32 +0,0 @@\n-The parser is a series of edges/transitions, for example:\n-r -> reddit.com/ \n-where -> means that all of\n-  r->reddit.com/\n-  re->reddit.com/\n-  red->reddit.com/\n-  redd->reddit.com/\n-  reddi->reddit.com/\n-  reddit->reddit.com/\n-  reddit.->reddit.com/\n-  reddit.c->reddit.com/\n-  reddit.co->reddit.com/\n-  reddit.com->reddt.com/\n-  are defined as autocomplete suggestions\n-\n-re -> reddit.com/\n-reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/\n-reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n-reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/\n-reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n-\n-\n-h->https:// (show supported domains)\n-\n-// the edges are composable such that this is possible, while reusing the above reddit code.\n-https://r->https://reddit.com/\n-https://w->https://www.\n-https://www.r->https://www.reddit.com/\n-\n-\n-// and i also want to have commands after you press space, like\n-reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})%                                                                                                                                                \ndiff --git a/ranking.rs b/ranking.rs\ndeleted file mode 100644\nindex 34241cf6bf13117b3ad3262761eb7f7b48b54d80..0000000000000000000000000000000000000000\n--- a/ranking.rs\n+++ /dev/null\n@@ -1,393 +0,0 @@\n-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  cat server/src/ranking.rs \n-use std::collections::{HashMap, HashSet};\n-\n-use crate::path_types::ItemId;\n-use crate::reducer::GroupState;\n-\n-#[derive(Debug, Clone)]\n-pub struct RankedItem {\n-    pub item: ItemId,\n-    pub score: f64,\n-}\n-\n-/// Compute connected components over the voted-pairs graph (treated as undirected).\n-///\n-/// Returns:\n-/// - `components`: each component is a sorted list of node indices, excluding isolates.\n-/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).\n-pub fn connected_components_from_voted_pairs(\n-    n: usize,\n-    voted_pairs: impl Iterator,\n-) -> (Vec>, Vec) {\n-    let mut adj: Vec> = vec![Vec::new(); n];\n-    for (a, b) in voted_pairs {\n-        if a >= n || b >= n || a == b {\n-            continue;\n-        }\n-        adj[a].push(b);\n-        adj[b].push(a);\n-    }\n-\n-    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();\n-    isolates.sort();\n-\n-    let mut seen = vec![false; n];\n-    for &i in &isolates {\n-        seen[i] = true;\n-    }\n-\n-    let mut comps: Vec> = Vec::new();\n-    for i in 0..n {\n-        if seen[i] {\n-            continue;\n-        }\n-        let mut stack = vec![i];\n-        seen[i] = true;\n-        let mut comp: Vec = Vec::new();\n-        while let Some(x) = stack.pop() {\n-            comp.push(x);\n-            for &y in &adj[x] {\n-                if !seen[y] {\n-                    seen[y] = true;\n-                    stack.push(y);\n-                }\n-            }\n-        }\n-        comp.sort();\n-        comps.push(comp);\n-    }\n-\n-    (comps, isolates)\n-}\n-\n-/// Compute rank centrality scores for a GroupState.\n-/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing\n-/// an O(E) multiply per iteration.\n-pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {\n-    if !group.dirty && !group.cached_scores.is_empty() {\n-        return;\n-    }\n-\n-    let n = group.idx_to_item.len();\n-    if n == 0 {\n-        group.cached_scores = vec![];\n-        group.dirty = false;\n-        return;\n-    }\n-    if n == 1 {\n-        group.cached_scores = vec![1.0];\n-        group.dirty = false;\n-        return;\n-    }\n-\n-    let scores = compute_scores_from_edges(\n-        n,\n-        group.edges.iter().map(|(&k, &w)| (k, w)),\n-        max_iters,\n-        tol,\n-    );\n-    group.cached_scores = scores;\n-    group.dirty = false;\n-}\n-\n-pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {\n-    compute_group_ranking(group, max_iters, tol);\n-    let mut items: Vec = group\n-        .idx_to_item\n-        .iter()\n-        .enumerate()\n-        .map(|(i, item)| RankedItem {\n-            item: item.clone(),\n-            score: *group.cached_scores.get(i).unwrap_or(&0.0),\n-        })\n-        .collect();\n-\n-    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n-    items\n-}\n-\n-fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {\n-    if n == 0 {\n-        return vec![];\n-    }\n-    if n == 1 {\n-        return vec![1.0];\n-    }\n-\n-    // Collect raw edges into a map for pairwise normalization.\n-    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();\n-    for ((src, dst), w) in edges {\n-        if src >= n || dst >= n || w <= 0.0 {\n-            continue;\n-        }\n-        *raw.entry((src, dst)).or_insert(0.0) += w;\n-    }\n-\n-    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).\n-    // This ensures repeated votes on the same pair don't inflate influence\n-    // beyond what the ratio implies.\n-    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();\n-    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();\n-    for (i, j) in keys {\n-        if normalized.contains_key(&(i, j)) {\n-            continue;\n-        }\n-        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);\n-        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);\n-        let total = w_ij + w_ji;\n-        if total <= 0.0 {\n-            continue;\n-        }\n-        normalized.insert((i, j), w_ij / total);\n-        if w_ji > 0.0 {\n-            normalized.insert((j, i), w_ji / total);\n-        }\n-    }\n-\n-    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):\n-    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared\n-    //   P_ii = 1 - (1/d_max) * Σ_k A_ik\n-    // where d_i is the *degree* (number of distinct neighbors compared) and\n-    // d_max = max_i d_i. Using the unweighted degree — not the sum of\n-    // pairwise-normalized weights — is what guarantees aperiodicity: it\n-    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree\n-    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n-    // loss). Without this, regular comparison graphs (e.g. a pure star at\n-    // ratio 2:1) produce a bipartite chain that oscillates instead of\n-    // converging — see issue #146.\n-    let mut out_edges: Vec> = vec![Vec::new(); n];\n-    let mut neighbors: Vec> = vec![HashSet::new(); n];\n-\n-    for ((src, dst), w) in &normalized {\n-        out_edges[*src].push((*dst, *w));\n-        neighbors[*src].insert(*dst);\n-        neighbors[*dst].insert(*src);\n-    }\n-\n-    let weight_sum: Vec = out_edges\n-        .iter()\n-        .map(|es| es.iter().map(|(_, w)| *w).sum())\n-        .collect();\n-    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n-    if d_max == 0 {\n-        return vec![1.0 / n as f64; n];\n-    }\n-    let d_max_f = d_max as f64;\n-\n-    let mut scores = vec![1.0 / n as f64; n];\n-    let mut next = vec![0.0f64; n];\n-\n-    for _ in 0..max_iters {\n-        next.fill(0.0);\n-        for i in 0..n {\n-            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;\n-            next[i] += scores[i] * stay_prob;\n-\n-            if out_edges[i].is_empty() {\n-                continue;\n-            }\n-            for &(dst, w) in &out_edges[i] {\n-                next[dst] += scores[i] * (w / d_max_f);\n-            }\n-        }\n-\n-        let diff: f64 = scores\n-            .iter()\n-            .zip(next.iter())\n-            .map(|(a, b)| (a - b).abs())\n-            .sum();\n-\n-        scores.clone_from_slice(&next);\n-        if diff < tol {\n-            break;\n-        }\n-    }\n-\n-    let sum: f64 = scores.iter().sum();\n-    if sum.is_finite() && sum > 0.0 {\n-        for s in &mut scores {\n-            *s /= sum;\n-        }\n-    }\n-    scores\n-}\n-\n-/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.\n-///\n-/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.\n-pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {\n-    if idxs.is_empty() {\n-        return vec![];\n-    }\n-\n-    // Map original idx -> compact idx [0..m)\n-    let mut map: HashMap = HashMap::with_capacity(idxs.len());\n-    for (j, &i) in idxs.iter().enumerate() {\n-        map.insert(i, j);\n-    }\n-\n-    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {\n-        let s = *map.get(&src)?;\n-        let d = *map.get(&dst)?;\n-        Some(((s, d), w))\n-    });\n-\n-    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);\n-\n-    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).\n-    let mut items: Vec = idxs\n-        .iter()\n-        .enumerate()\n-        .filter_map(|(j, &orig)| {\n-            let item = group.idx_to_item.get(orig)?.clone();\n-            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })\n-        })\n-        .collect();\n-\n-    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n-    items\n-}\n-\n-pub fn group_summary_scores(\n-    group: &mut GroupState,\n-    max_iters: usize,\n-    tol: f64,\n-) -> HashMap {\n-    ranked_items(group, max_iters, tol)\n-        .into_iter()\n-        .map(|r| (r.item, r.score))\n-        .collect()\n-}\n-\n-#[cfg(test)]\n-mod tests {\n-    use super::*;\n-    use crate::reducer::VoteData;\n-\n-    fn mk_group() -> GroupState {\n-        GroupState::new()\n-    }\n-\n-    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n-        use crate::path_types::ItemId;\n-        VoteData {\n-            ts,\n-            a: ItemId::parse(a).unwrap(),\n-            b: ItemId::parse(b).unwrap(),\n-            ratio_left: l,\n-            ratio_right: r,\n-            body: \"because\".to_string(),\n-            principal: \"test\".to_string(),\n-            delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n-            thread_tag: \"untagged\".to_string(),\n-        }\n-    }\n-\n-    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n-    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n-    /// chain was bipartite; power iteration oscillated and returned the\n-    /// uniform initial distribution after an even number of steps. Using the\n-    /// paper's degree-based d_max gives every node a positive self-loop and\n-    /// the chain converges to the correct stationary distribution.\n-    #[test]\n-    fn star_topology_winner_at_top_via_subset() {\n-        let mut g = mk_group();\n-        g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n-        g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n-\n-        let mut items: Vec<(usize, String)> = g\n-            .idx_to_item\n-            .iter()\n-            .enumerate()\n-            .map(|(i, it)| (i, it.as_str().to_string()))\n-            .collect();\n-        items.sort_by(|a, b| a.1.cmp(&b.1));\n-        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n-\n-        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n-        for r in &ranked {\n-            eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n-        }\n-        assert_eq!(\n-            ranked[0].item.as_str(),\n-            \"https://slug.social/zebra\",\n-            \"zebra won both votes and should rank #1\"\n-        );\n-    }\n-\n-    #[test]\n-    fn group_ranking_cache_dirty_flow() {\n-        let mut g = mk_group();\n-        assert!(g.dirty);\n-\n-        g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n-        assert!(g.dirty);\n-        assert!(g.cached_scores.is_empty());\n-\n-        compute_group_ranking(&mut g, 10000, 1e-8);\n-        assert!(!g.dirty);\n-        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());\n-\n-        // Recomputing when not dirty should be a no-op.\n-        let before = g.cached_scores.clone();\n-        compute_group_ranking(&mut g, 10000, 1e-8);\n-        assert_eq!(before, g.cached_scores);\n-    }\n-\n-    #[test]\n-    fn connected_components_split_disconnected_pairs() {\n-        let mut g = mk_group();\n-        // Two disconnected edges: (a,b) and (c,d)\n-        g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n-        g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n-\n-        let n = g.idx_to_item.len();\n-        let (mut comps, isolates) =\n-            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());\n-        assert!(isolates.is_empty());\n-        // Order-independent: sort components by their item names for stable assert.\n-        comps.sort_by_key(|c| {\n-            c.iter()\n-                .map(|&i| g.idx_to_item[i].clone())\n-                .collect::>()\n-        });\n-        assert_eq!(comps.len(), 2);\n-        let comp0 = comps[0]\n-            .iter()\n-            .map(|&i| g.idx_to_item[i].as_str())\n-            .collect::>();\n-        let comp1 = comps[1]\n-            .iter()\n-            .map(|&i| g.idx_to_item[i].as_str())\n-            .collect::>();\n-        assert_eq!(comp0, vec![\"https://slug.social/a\", \"https://slug.social/b\"]);\n-        assert_eq!(comp1, vec![\"https://slug.social/c\", \"https://slug.social/d\"]);\n-    }\n-\n-    #[test]\n-    fn subset_ranking_ranks_within_component_only() {\n-        let mut g = mk_group();\n-        g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n-        g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n-\n-        let (comps, _) =\n-            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());\n-        assert_eq!(comps.len(), 2);\n-\n-        // Rank each component and ensure winner is first within that component.\n-        for comp in comps {\n-            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);\n-            assert_eq!(ranked.len(), 2);\n-            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();\n-            if names.contains(&\"https://slug.social/a\") {\n-                assert_eq!(names[0], \"https://slug.social/a\");\n-            } else {\n-                assert_eq!(names[0], \"https://slug.social/d\");\n-            }\n-        }\n-    }\n-}\n-\n-\n-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  \n-\ndiff --git a/reddit.rs b/reddit.rs\ndeleted file mode 100644\nindex da27f6f76482dd27d44cad9d7b844972bf042e64..0000000000000000000000000000000000000000\n--- a/reddit.rs\n+++ /dev/null\n@@ -1,420 +0,0 @@\n-use governor::{Quota, RateLimiter, Jitter};\n-use nonzero_ext::nonzero;\n-use reqwest::{Client, StatusCode};\n-use serde::{Deserialize, Serialize};\n-use std::sync::Arc;\n-use std::time::Duration;\n-use anyhow::{Result, Context, anyhow};\n-\n-/// Reddit API client with built-in rate limiting\n-pub struct RedditClient {\n-    client: Client,\n-    limiter: Arc,\n-    user_agent: String,\n-}\n-\n-impl RedditClient {\n-    /// Create a new Reddit client with rate limiting\n-    /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps\n-    /// We'll be conservative and use 50 requests per minute\n-    pub fn new() -> Self {\n-        // Create rate limiter: 50 requests per minute\n-        let quota = Quota::per_minute(nonzero!(50u32));\n-        let limiter = Arc::new(RateLimiter::direct(quota));\n-        \n-        // Create HTTP client with timeout\n-        let client = Client::builder()\n-            .timeout(Duration::from_secs(30))\n-            .build()\n-            .expect(\"Failed to create HTTP client\");\n-        \n-        // Reddit requires a unique user agent\n-        let user_agent = format!(\n-            \"rust:sorter:v{} (by /u/hourLong_arnould)\",\n-            env!(\"CARGO_PKG_VERSION\")\n-        );\n-        \n-        Self {\n-            client,\n-            limiter,\n-            user_agent,\n-        }\n-    }\n-    \n-    /// Fetch hot posts from a subreddit\n-    pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result {\n-        // Wait for rate limiter\n-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n-        \n-        let url = format!(\"https://www.reddit.com/r/{}/hot.json?limit={}\", subreddit, limit);\n-        \n-        let response = self.client\n-            .get(&url)\n-            .header(\"User-Agent\", &self.user_agent)\n-            .send()\n-            .await\n-            .context(\"Failed to send request to Reddit\")?;\n-        \n-        self.handle_response(response).await\n-    }\n-    \n-    /// Fetch top posts from a subreddit\n-    pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result {\n-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n-        \n-        let url = format!(\n-            \"https://www.reddit.com/r/{}/top.json?limit={}&t={}\",\n-            subreddit, limit, time_period\n-        );\n-        \n-        let response = self.client\n-            .get(&url)\n-            .header(\"User-Agent\", &self.user_agent)\n-            .send()\n-            .await\n-            .context(\"Failed to send request to Reddit\")?;\n-        \n-        self.handle_response(response).await\n-    }\n-    \n-    /// Fetch new posts from a subreddit\n-    pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result {\n-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n-        \n-        let url = format!(\"https://www.reddit.com/r/{}/new.json?limit={}\", subreddit, limit);\n-        \n-        let response = self.client\n-            .get(&url)\n-            .header(\"User-Agent\", &self.user_agent)\n-            .send()\n-            .await\n-            .context(\"Failed to send request to Reddit\")?;\n-        \n-        self.handle_response(response).await\n-    }\n-    \n-    /// Fetch a specific post and its comments\n-    pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result> {\n-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n-        \n-        let url = format!(\n-            \"https://www.reddit.com/r/{}/comments/{}.json\",\n-            subreddit, post_id\n-        );\n-        \n-        let response = self.client\n-            .get(&url)\n-            .header(\"User-Agent\", &self.user_agent)\n-            .send()\n-            .await\n-            .context(\"Failed to send request to Reddit\")?;\n-        \n-        match response.status() {\n-            StatusCode::OK => {\n-                let listings: Vec = response\n-                    .json()\n-                    .await\n-                    .context(\"Failed to parse Reddit response\")?;\n-                Ok(listings)\n-            }\n-            StatusCode::TOO_MANY_REQUESTS => {\n-                Err(anyhow!(\"Reddit rate limit exceeded. Please try again later.\"))\n-            }\n-            StatusCode::NOT_FOUND => {\n-                Err(anyhow!(\"Post not found: r/{}/comments/{}\", subreddit, post_id))\n-            }\n-            status => {\n-                Err(anyhow!(\"Reddit API error: {}\", status))\n-            }\n-        }\n-    }\n-    \n-    /// Fetch user profile (posts and comments)\n-    pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result {\n-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;\n-        \n-        let url = format!(\n-            \"https://www.reddit.com/user/{}/{}.json?limit={}\",\n-            username, content_type, limit\n-        );\n-        \n-        let response = self.client\n-            .get(&url)\n-            .header(\"User-Agent\", &self.user_agent)\n-            .send()\n-            .await\n-            .context(\"Failed to send request to Reddit\")?;\n-        \n-        self.handle_response(response).await\n-    }\n-    \n-    /// Handle Reddit API response with proper error checking\n-    async fn handle_response(&self, response: reqwest::Response) -> Result {\n-        match response.status() {\n-            StatusCode::OK => {\n-                let listing: RedditListingResponse = response\n-                    .json()\n-                    .await\n-                    .context(\"Failed to parse Reddit response\")?;\n-                Ok(listing)\n-            }\n-            StatusCode::TOO_MANY_REQUESTS => {\n-                Err(anyhow!(\"Reddit rate limit exceeded. Please try again later.\"))\n-            }\n-            StatusCode::NOT_FOUND => {\n-                Err(anyhow!(\"Reddit resource not found\"))\n-            }\n-            StatusCode::FORBIDDEN => {\n-                Err(anyhow!(\"Access forbidden. The subreddit may be private.\"))\n-            }\n-            status => {\n-                Err(anyhow!(\"Reddit API error: {}\", status))\n-            }\n-        }\n-    }\n-}\n-\n-// Reddit API response types\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-pub struct RedditListingResponse {\n-    pub kind: String,\n-    pub data: RedditListingData,\n-}\n-\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-pub struct RedditListingData {\n-    #[serde(skip_serializing_if = \"Option::is_none\")]\n-    pub after: Option,\n-    #[serde(skip_serializing_if = \"Option::is_none\")]\n-    pub before: Option,\n-    pub children: Vec,\n-    #[serde(skip_serializing_if = \"Option::is_none\")]\n-    pub modhash: Option,\n-}\n-\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-pub struct RedditThing {\n-    pub kind: String,\n-    pub data: RedditThingData,\n-}\n-\n-/// Reddit's \"Thing\" data can be either a post, comment, or other types\n-/// We use untagged enum to handle different data structures\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-#[serde(untagged)]\n-pub enum RedditThingData {\n-    Post(RedditPost),\n-    Comment(RedditComment),\n-    // For things we don't care about yet (like \"more\" comments)\n-    Other(serde_json::Value),\n-}\n-\n-/// Reddit post data with serde handling all the parsing\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-pub struct RedditPost {\n-    pub id: String,\n-    pub name: String, // Full name (t3_xxx)\n-    #[serde(default = \"default_untitled\")]\n-    pub title: String,\n-    #[serde(default = \"default_deleted_user\")]\n-    pub author: String,\n-    pub subreddit: String,\n-    #[serde(default)]\n-    pub url: String,\n-    #[serde(default)]\n-    pub permalink: String,\n-    #[serde(default, deserialize_with = \"deserialize_selftext\")]\n-    pub selftext: Option,\n-    #[serde(default)]\n-    pub score: i64,\n-    #[serde(default)]\n-    pub num_comments: i64,\n-    #[serde(default)]\n-    pub created_utc: f64,\n-    #[serde(default, deserialize_with = \"deserialize_thumbnail\")]\n-    pub thumbnail: Option,\n-    #[serde(default)]\n-    pub is_video: bool,\n-    #[serde(default)]\n-    pub is_self: bool,\n-    // Additional useful fields\n-    #[serde(default)]\n-    pub ups: i64,\n-    #[serde(default)]\n-    pub downs: i64,\n-    #[serde(default)]\n-    pub upvote_ratio: f64,\n-    #[serde(default)]\n-    pub over_18: bool,\n-    #[serde(default)]\n-    pub spoiler: bool,\n-    #[serde(default)]\n-    pub stickied: bool,\n-    #[serde(default)]\n-    pub locked: bool,\n-    #[serde(default)]\n-    pub distinguished: Option,\n-}\n-\n-/// Reddit comment data\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-pub struct RedditComment {\n-    pub id: String,\n-    pub name: String, // Full name (t1_xxx)\n-    #[serde(default = \"default_deleted_user\")]\n-    pub author: String,\n-    #[serde(default)]\n-    pub body: String,\n-    #[serde(default)]\n-    pub score: i64,\n-    #[serde(default)]\n-    pub created_utc: f64,\n-    #[serde(default)]\n-    pub parent_id: String,\n-    #[serde(default)]\n-    pub permalink: String,\n-    #[serde(default)]\n-    pub depth: i32,\n-    // Additional useful fields\n-    #[serde(default)]\n-    pub ups: i64,\n-    #[serde(default)]\n-    pub downs: i64,\n-    #[serde(default)]\n-    pub edited: RedditEditStatus,\n-    #[serde(default)]\n-    pub stickied: bool,\n-    #[serde(default)]\n-    pub distinguished: Option,\n-    #[serde(default)]\n-    pub is_submitter: bool,\n-    #[serde(default)]\n-    pub collapsed: bool,\n-    #[serde(default)]\n-    pub controversiality: i32,\n-}\n-\n-/// Reddit's edit status - can be false or a timestamp\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-#[serde(untagged)]\n-pub enum RedditEditStatus {\n-    NotEdited(bool),\n-    EditedAt(f64),\n-}\n-\n-impl Default for RedditEditStatus {\n-    fn default() -> Self {\n-        RedditEditStatus::NotEdited(false)\n-    }\n-}\n-\n-// Helper functions for serde defaults\n-fn default_untitled() -> String {\n-    \"Untitled\".to_string()\n-}\n-\n-fn default_deleted_user() -> String {\n-    \"[deleted]\".to_string()\n-}\n-\n-// Custom deserializer for selftext (empty strings should be None)\n-fn deserialize_selftext<'de, D>(deserializer: D) -> Result, D::Error>\n-where\n-    D: serde::Deserializer<'de>,\n-{\n-    let s: Option = Option::deserialize(deserializer)?;\n-    Ok(s.filter(|text| !text.is_empty()))\n-}\n-\n-// Custom deserializer for thumbnail (filter out \"self\", \"default\", empty)\n-fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result, D::Error>\n-where\n-    D: serde::Deserializer<'de>,\n-{\n-    let s: Option = Option::deserialize(deserializer)?;\n-    Ok(s.filter(|thumb| {\n-        !thumb.is_empty() && thumb != \"self\" && thumb != \"default\" && thumb != \"nsfw\" && thumb != \"spoiler\"\n-    }))\n-}\n-\n-// Helper methods for extracting typed data from Things\n-impl RedditThing {\n-    /// Try to get this Thing as a Post\n-    pub fn as_post(&self) -> Option<&RedditPost> {\n-        if self.kind != \"t3\" {\n-            return None;\n-        }\n-        match &self.data {\n-            RedditThingData::Post(post) => Some(post),\n-            _ => None,\n-        }\n-    }\n-    \n-    /// Try to get this Thing as a Comment\n-    pub fn as_comment(&self) -> Option<&RedditComment> {\n-        if self.kind != \"t1\" {\n-            return None;\n-        }\n-        match &self.data {\n-            RedditThingData::Comment(comment) => Some(comment),\n-            _ => None,\n-        }\n-    }\n-    \n-    /// Check if this is a \"more comments\" indicator\n-    pub fn is_more_comments(&self) -> bool {\n-        self.kind == \"more\"\n-    }\n-}\n-\n-// Helper methods for working with listing responses\n-impl RedditListingResponse {\n-    /// Extract all posts from this listing\n-    pub fn get_posts(&self) -> Vec<&RedditPost> {\n-        self.data.children.iter()\n-            .filter_map(|thing| thing.as_post())\n-            .collect()\n-    }\n-    \n-    /// Extract all comments from this listing\n-    pub fn get_comments(&self) -> Vec<&RedditComment> {\n-        self.data.children.iter()\n-            .filter_map(|thing| thing.as_comment())\n-            .collect()\n-    }\n-    \n-    /// Extract posts as owned values\n-    pub fn into_posts(self) -> Vec {\n-        self.data.children.into_iter()\n-            .filter_map(|thing| {\n-                if thing.kind == \"t3\" {\n-                    match thing.data {\n-                        RedditThingData::Post(post) => Some(post),\n-                        _ => None,\n-                    }\n-                } else {\n-                    None\n-                }\n-            })\n-            .collect()\n-    }\n-    \n-    /// Extract comments as owned values\n-    pub fn into_comments(self) -> Vec {\n-        self.data.children.into_iter()\n-            .filter_map(|thing| {\n-                if thing.kind == \"t1\" {\n-                    match thing.data {\n-                        RedditThingData::Comment(comment) => Some(comment),\n-                        _ => None,\n-                    }\n-                } else {\n-                    None\n-                }\n-            })\n-            .collect()\n-    }\n-}\n-\n-\n-\ndiff --git a/reducer.rs b/reducer.rs\ndeleted file mode 100644\nindex 1ba8030feaf783b066b2e0bba4d2d05659c992f4..0000000000000000000000000000000000000000\n--- a/reducer.rs\n+++ /dev/null\n@@ -1,845 +0,0 @@\n-use std::collections::{HashMap, HashSet, VecDeque};\n-\n-use serde::{Deserialize, Serialize};\n-\n-use crate::canonical_path::canonicalize_tag;\n-use crate::dsl;\n-use crate::events::{Event, Ingest, ThreadCapability};\n-use crate::path_types::ItemId;\n-\n-#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n-pub enum ScopeId {\n-    Public,\n-    Room(String),\n-}\n-\n-/// Wire `room` field → content scope (`\"public\"` → [`ScopeId::Public`]).\n-pub fn scope_from_room_wire(room: &str) -> ScopeId {\n-    let r = room.trim();\n-    if r.is_empty() || r == \"public\" {\n-        ScopeId::Public\n-    } else {\n-        ScopeId::Room(r.to_string())\n-    }\n-}\n-\n-/// Parsed vote data (internal representation).\n-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n-pub struct VoteData {\n-    pub ts: i64,\n-    pub a: ItemId,\n-    pub b: ItemId,\n-    pub ratio_left: i32,\n-    pub ratio_right: i32,\n-    pub body: String,\n-    pub principal: String,\n-    pub delegate: Option,\n-    /// Forum channel where this vote was cast (tag only, not room id).\n-    pub thread_tag: String,\n-}\n-\n-#[derive(Debug, Clone)]\n-pub struct GroupState {\n-    pub item_to_idx: HashMap,\n-    pub idx_to_item: Vec,\n-\n-    /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.\n-    pub edges: HashMap<(usize, usize), f64>,\n-\n-    /// Unordered pairs that have at least one vote recorded between them (i,\n-    pub dirty: bool,\n-    pub cached_scores: Vec,\n-    pub recent_votes: VecDeque,\n-}\n-\n-impl Default for GroupState {\n-    fn default() -> Self {\n-        Self::new()\n-    }\n-}\n-\n-impl GroupState {\n-    pub fn new() -> Self {\n-        Self {\n-            item_to_idx: HashMap::new(),\n-            idx_to_item: Vec::new(),\n-            edges: HashMap::new(),\n-            voted_pairs: HashSet::new(),\n-            dirty: true,\n-            cached_scores: Vec::new(),\n-            recent_votes: VecDeque::with_capacity(200),\n-        }\n-    }\n-\n-    fn ensure_item(&mut self, item: &ItemId) -> usize {\n-        if let Some(&idx) = self.item_to_idx.get(item) {\n-            return idx;\n-        }\n-        let idx = self.idx_to_item.len();\n-        self.idx_to_item.push(item.clone());\n-        self.item_to_idx.insert(item.clone(), idx);\n-        self.dirty = true;\n-        idx\n-    }\n-\n-    /// Public test helper: insert an item into the group without a vote (for unit tests).\n-    pub fn ensure_item_pub(&mut self, item: &str) -> usize {\n-        if let Some(canon) = ItemId::parse(item) {\n-            self.ensure_item(&canon)\n-        } else {\n-            // Fallback: treat as raw storage string\n-            let canon = ItemId::opaque(item.to_string());\n-            self.ensure_item(&canon)\n-        }\n-    }\n-\n-    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n-        if w <= 0.0 {\n-            return;\n-        }\n-        *self.edges.entry((src, dst)).or_insert(0.0) += w;\n-        self.dirty = true;\n-    }\n-\n-    pub fn apply_vote(&mut self, mut vote: VoteData) {\n-        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n-        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n-        vote.thread_tag = canonicalize_tag(&vote.thread_tag);\n-        if vote.ratio_left < 0 {\n-            vote.ratio_left = 0;\n-        }\n-        if vote.ratio_right < 0 {\n-            vote.ratio_right = 0;\n-        }\n-        let a_idx = self.ensure_item(&vote.a);\n-        let b_idx = self.ensure_item(&vote.b);\n-\n-        let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };\n-        self.voted_pairs.insert((i, j));\n-\n-        let w_a = vote.ratio_left as f64;\n-        let w_b = vote.ratio_right as f64;\n-\n-        self.add_edge_weight(b_idx, a_idx, w_a);\n-        self.add_edge_weight(a_idx, b_idx, w_b);\n-\n-        self.recent_votes.push_front(vote);\n-        while self.recent_votes.len() > 200 {\n-            self.recent_votes.pop_back();\n-        }\n-    }\n-}\n-\n-/// Compact rank-history entry stored per item in the ledger.\n-/// `caused_by` is resolved lazily at query time from `ingests_by_id`.\n-#[derive(Debug, Clone)]\n-pub struct RankHistoryEntry {\n-    pub ts: i64,\n-    pub scope_rank: usize,\n-    pub scope_rank_delta: i32,\n-    pub scope_total: usize,\n-    pub global_rank: usize,\n-    pub global_rank_delta: i32,\n-    pub global_total: usize,\n-    pub score: f64,\n-    pub thread: String,\n-    pub post_id: String,\n-}\n-\n-/// Invite link materialized from log events [`crate::events::InviteMinted`] /\n-/// [`crate::events::InviteRedeemed`] when those are replayed.\n-///\n-/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);\n-/// they are not appended to the JSONL log, so they disappear on restart. This struct is for\n-/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.\n-#[derive(Debug, Clone)]\n-pub struct ActiveInviteState {\n-    pub room_id: String,\n-    pub capabilities: HashSet,\n-    pub inviter: String,\n-    pub uses_remaining: u32,\n-    pub expires_ts_ms: Option,\n-}\n-\n-#[derive(Clone, Debug)]\n-pub enum RoomTimelineKind {\n-    RoomCreated {\n-        owner: String,\n-        slug: String,\n-    },\n-    RoomDeleted {\n-        deleted_by: String,\n-    },\n-    GrantAdded {\n-        username: String,\n-        granted_by: String,\n-        capabilities: Vec,\n-    },\n-    GrantRevoked {\n-        username: String,\n-        revoked_by: String,\n-        capabilities: Vec,\n-    },\n-}\n-\n-#[derive(Clone, Debug)]\n-pub struct RoomTimelineEntry {\n-    pub ts: i64,\n-    pub kind: RoomTimelineKind,\n-}\n-\n-#[derive(Debug, Clone)]\n-#[derive(Default)]\n-pub struct ForumThreadState {\n-    pub last_activity_ts: i64,\n-    /// Username of the most recent person who bumped this thread.\n-    pub last_actor: String,\n-}\n-\n-\n-#[derive(Debug, Clone, Default)]\n-pub struct ContentState {\n-    pub ranking_group: GroupState,\n-    pub items: HashSet,\n-    pub item_bodies: HashMap,\n-    /// Parent [`ItemId`] -> direct children.\n-    pub item_children: HashMap>,\n-    /// Per-item vote history (most recent first).\n-    pub item_votes: HashMap>,\n-    /// Per-item ingest references (most recent first).\n-    pub item_snippets: HashMap>,\n-    /// Item path -> threads that mention or vote on this item.\n-    pub item_threads: HashMap>,\n-    /// Per-item rank history, oldest first.\n-    pub rank_history: HashMap>,\n-}\n-\n-#[derive(Debug, Clone)]\n-pub struct ReducerState {\n-    pub content: HashMap,\n-\n-    /// (provider, provider_id) -> username\n-    pub users_by_provider: HashMap<(String, String), String>,\n-    /// token_id -> (username, salt, token_hash)\n-    pub tokens_by_id: HashMap,\n-    pub agent_bindings: HashMap,\n-\n-\n-    pub ingests_by_id: HashMap,\n-    /// (scope, thread_tag) → ingest ids, newest first.\n-    pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,\n-    /// Private room ids (`shortid/slug`) known from [`RoomCreated`].\n-    pub rooms: HashSet,\n-    /// (scope, thread_tag) → last activity.\n-    pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,\n-    pub actor_last_post_ts: HashMap,\n-\n-    /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.\n-    pub ingests_ordered: Vec,\n-    /// Username → ingest ids in post order (oldest first), for profile pages.\n-    pub posts_by_actor: HashMap>,\n-    /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).\n-    pub redacted_posts: HashSet,\n-    /// Redaction event timestamp (ms) per post id.\n-    pub post_redact_ts: HashMap,\n-    /// room_id → username → capabilities\n-    pub grants: HashMap>>,\n-    /// room_id → chronological room admin lines (for thread UI).\n-    pub room_timeline: HashMap>,\n-    /// Invite token → active invite (absent when fully consumed or never minted).\n-    pub invites: HashMap,\n-}\n-\n-impl ReducerState {\n-    pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {\n-        self.content.get(scope)\n-    }\n-\n-    /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).\n-    pub fn try_thread_post_index_chronological(\n-        &self,\n-        scope: &ScopeId,\n-        thread_tag: &str,\n-        post_id: &str,\n-    ) -> Option {\n-        let tag = canonicalize_tag(thread_tag);\n-        self.ingests_by_scope_thread\n-            .get(&(scope.clone(), tag))\n-            .and_then(|q| q.iter().rev().position(|pid| pid == post_id))\n-    }\n-\n-    pub fn thread_post_index_chronological(\n-        &self,\n-        scope: &ScopeId,\n-        thread_tag: &str,\n-        post_id: &str,\n-    ) -> usize {\n-        self.try_thread_post_index_chronological(scope, thread_tag, post_id)\n-            .expect(\n-                \"post_id must appear in ingests_by_scope_thread for this scope and thread\",\n-            )\n-    }\n-\n-    /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.\n-    pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {\n-        self.posts_by_actor\n-            .get(actor)\n-            .map(|q| q.iter().cloned().collect())\n-            .unwrap_or_default()\n-    }\n-\n-    /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.\n-    pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {\n-        self.posts_by_actor_ids(actor)\n-            .into_iter()\n-            .filter(|id| {\n-                !self.redacted_posts.contains(id)\n-                    && self.ingests_by_id.get(id).is_some_and(|ing| {\n-                        let scope = scope_from_room_wire(&ing.room_id);\n-                        match &scope {\n-                            ScopeId::Public => true,\n-                            ScopeId::Room(rid) => {\n-                                viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))\n-                            }\n-                        }\n-                    })\n-            })\n-            .collect()\n-    }\n-\n-    pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {\n-        self.grants\n-            .get(room_id)\n-            .and_then(|t| t.get(username))\n-            .map(|caps| caps.contains(&cap))\n-            .unwrap_or(false)\n-    }\n-\n-    /// Invite link is present, not expired, and has uses left.\n-    pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {\n-        let inv = self.invites.get(token)?;\n-        if inv.uses_remaining == 0 {\n-            return None;\n-        }\n-        if let Some(exp) = inv.expires_ts_ms {\n-            if now_ms > exp {\n-                return None;\n-            }\n-        }\n-        Some(inv)\n-    }\n-\n-    pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {\n-        self.content.entry(scope).or_default()\n-    }\n-\n-    pub fn public(&self) -> &ContentState {\n-        self.content.get(&ScopeId::Public).expect(\"public scope missing\")\n-    }\n-\n-    /// Register parent→child edges for the full ancestor chain.\n-    /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `\"\"→a`.\n-    /// Stops early when an intermediate is already registered (its ancestors must be too).\n-    /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4\n-    fn add_child_edge(content: &mut ContentState, item: &ItemId) {\n-        let mut child = item.clone();\n-        loop {\n-            let Some(parent) = child.parent() else { break };\n-            let is_new = content\n-                .item_children\n-                .entry(parent.clone())\n-                .or_default()\n-                .insert(child);\n-            if !is_new { break; }\n-            child = parent;\n-        }\n-    }\n-\n-    /// Resolve an item path as a first-class [`ItemId`].\n-    fn normalize_item(item: &str) -> Option {\n-        ItemId::parse(item)\n-    }\n-\n-    /// 1-indexed rank of `item` within its connected component in the parent scope.\n-    /// 0 if the item has no votes connecting it to siblings (unranked).\n-    fn scope_rank_of(\n-        group: &GroupState,\n-        item: &ItemId,\n-        item_children: &HashMap>,\n-    ) -> usize {\n-        let scope = match item.parent() {\n-            Some(p) => p,\n-            None => return 0,\n-        };\n-        let children = match item_children.get(&scope) {\n-            None => return 0,\n-            Some(c) => c,\n-        };\n-        let &item_global_idx = match group.item_to_idx.get(item) {\n-            None => return 0,\n-            Some(i) => i,\n-        };\n-        // Map scope children to compact local indices.\n-        let sibling_idxs: Vec = children.iter()\n-            .filter_map(|c| group.item_to_idx.get(c).copied())\n-            .collect();\n-        let global_to_local: HashMap = sibling_idxs.iter()\n-            .enumerate().map(|(l, &g)| (g, l)).collect();\n-        let item_local = match global_to_local.get(&item_global_idx) {\n-            None => return 0,\n-            Some(&l) => l,\n-        };\n-        // Connected components within scope.\n-        let (comps, _) = crate::ranking::connected_components_from_voted_pairs(\n-            sibling_idxs.len(),\n-            group.voted_pairs.iter().filter_map(|(a, b)| {\n-                Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))\n-            }),\n-        );\n-        // Find the component containing this item.\n-        let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {\n-            None => return 0,\n-            Some(c) => c,\n-        };\n-        let comp_global: Vec = comp_local.iter()\n-            .filter_map(|&l| sibling_idxs.get(l).copied())\n-            .collect();\n-        let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);\n-        ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)\n-    }\n-\n-    /// 1-indexed position of `item` in the component-aware global flat list.\n-    /// Components sorted largest-first; items ranked within each component.\n-    /// 0 if the item is not in the ranking group.\n-    fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {\n-        if !group.item_to_idx.contains_key(item) {\n-            return 0;\n-        }\n-        let n = group.idx_to_item.len();\n-        let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(\n-            n, group.voted_pairs.iter().copied(),\n-        );\n-        comps.sort_by_key(|b| std::cmp::Reverse(b.len()));\n-        let mut pos = 1usize;\n-        for comp in &comps {\n-            let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);\n-            for r in &ranked {\n-                if &r.item == item {\n-                    return pos;\n-                }\n-                pos += 1;\n-            }\n-        }\n-        0\n-    }\n-\n-    /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).\n-    fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {\n-        let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;\n-        let canonical_thread = canonicalize_tag(&ing.thread_tag);\n-\n-        let voted_items: Vec = doc\n-            .statements\n-            .iter()\n-            .filter_map(|s| {\n-                if let dsl::Stmt::Vote { item1, item2, .. } = s {\n-                    Some([item1, item2])\n-                } else {\n-                    None\n-                }\n-            })\n-            .flat_map(|pair| pair.into_iter())\n-            .filter_map(|raw| Self::normalize_item(raw))\n-            .collect::>()\n-            .into_iter()\n-            .collect();\n-\n-        let principal = ing.principal.clone();\n-        let delegate = ing.delegate.clone();\n-\n-        let before: HashMap = if !voted_items.is_empty() {\n-            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n-            voted_items\n-                .iter()\n-                .map(|it| {\n-                    (\n-                        it.clone(),\n-                        (\n-                            Self::scope_rank_of(&content.ranking_group, it, &content.item_children),\n-                            Self::global_rank_of(&content.ranking_group, it),\n-                        ),\n-                    )\n-                })\n-                .collect()\n-        } else {\n-            HashMap::new()\n-        };\n-\n-        let mut ingest_items: HashSet = HashSet::new();\n-\n-        for stmt in doc.statements {\n-            match stmt {\n-                dsl::Stmt::Item { title, body } => {\n-                    let Some(item) = Self::normalize_item(&title) else {\n-                        continue;\n-                    };\n-                    nav!(content.items, set_elem(item.clone()));\n-                    ingest_items.insert(item.clone());\n-                    Self::add_child_edge(content, &item);\n-\n-                    if let Some(body_text) = body {\n-                        if !body_text.trim().is_empty() {\n-                            nav!(content.item_bodies, keypath(item.clone()), setval(body_text));\n-                        }\n-                    }\n-                }\n-                dsl::Stmt::Vote {\n-                    item1,\n-                    item2,\n-                    ratio_left,\n-                    ratio_right,\n-                    explanation,\n-                } => {\n-                    let Some(item_a) = Self::normalize_item(&item1) else {\n-                        continue;\n-                    };\n-                    let Some(item_b) = Self::normalize_item(&item2) else {\n-                        continue;\n-                    };\n-\n-                    let vote = VoteData {\n-                        ts: ing.ts,\n-                        a: item_a.clone(),\n-                        b: item_b.clone(),\n-                        ratio_left,\n-                        ratio_right,\n-                        body: explanation,\n-                        principal: principal.clone(),\n-                        delegate: delegate.clone(),\n-                        thread_tag: canonical_thread.clone(),\n-                    };\n-\n-                    ingest_items.insert(item_a.clone());\n-                    ingest_items.insert(item_b.clone());\n-\n-                    nav!(content.items, set_elem(item_a.clone()));\n-                    nav!(content.items, set_elem(item_b.clone()));\n-                    Self::add_child_edge(content, &item_a);\n-                    Self::add_child_edge(content, &item_b);\n-\n-                    content.ranking_group.apply_vote(vote.clone());\n-\n-                    for it in [&item_a, &item_b] {\n-                        nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));\n-                    }\n-                }\n-                dsl::Stmt::Prose { .. } => {}\n-            }\n-        }\n-\n-        for item in ingest_items.iter() {\n-            nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));\n-        }\n-\n-        for item in ingest_items.iter() {\n-            nav!(\n-                content.item_threads,\n-                keypath(item.clone()),\n-                set_elem(canonical_thread.clone())\n-            );\n-        }\n-\n-        if !voted_items.is_empty() {\n-            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n-            let thread = canonical_thread.clone();\n-            for item in &voted_items {\n-                let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);\n-                let after_global = Self::global_rank_of(&content.ranking_group, item);\n-                let score = content\n-                    .ranking_group\n-                    .item_to_idx\n-                    .get(item)\n-                    .and_then(|&i| content.ranking_group.cached_scores.get(i))\n-                    .copied()\n-                    .unwrap_or(0.0);\n-                let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));\n-                let prev = content.rank_history.get(item).and_then(|v| v.last());\n-                let scope_delta = if prev.is_none() {\n-                    0\n-                } else {\n-                    after_scope as i32 - before_scope as i32\n-                };\n-                let global_delta = if prev.is_none() {\n-                    0\n-                } else {\n-                    after_global as i32 - before_global as i32\n-                };\n-                let scope_total = item\n-                    .parent()\n-                    .and_then(|p| content.item_children.get(&p))\n-                    .map(|s| s.len())\n-                    .unwrap_or(0);\n-                let global_total = content.ranking_group.idx_to_item.len();\n-                content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {\n-                    ts: ing.ts,\n-                    scope_rank: after_scope,\n-                    scope_rank_delta: scope_delta,\n-                    scope_total,\n-                    global_rank: after_global,\n-                    global_rank_delta: global_delta,\n-                    global_total,\n-                    score,\n-                    thread: thread.clone(),\n-                    post_id: ing.id.clone(),\n-                });\n-            }\n-        }\n-\n-        Ok(())\n-    }\n-\n-    fn rebuild_scope_content(&mut self, scope: ScopeId) {\n-        let mut cs = ContentState::default();\n-        for id in &self.ingests_ordered {\n-            if self.redacted_posts.contains(id) {\n-                continue;\n-            }\n-            let Some(ing) = self.ingests_by_id.get(id) else {\n-                continue;\n-            };\n-            if scope_from_room_wire(&ing.room_id) != scope {\n-                continue;\n-            }\n-            let _ = Self::apply_ingest_to_content(&mut cs, ing);\n-        }\n-        self.content.insert(scope, cs);\n-        // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so\n-        // per-post URLs and chronological indices remain stable; only projected garden state resets.\n-    }\n-\n-    /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).\n-    fn purge_private_room(&mut self, room_id: &str) {\n-        let scope = ScopeId::Room(room_id.to_string());\n-        self.rooms.remove(room_id);\n-        self.grants.remove(room_id);\n-        self.room_timeline.remove(room_id);\n-        self.invites.retain(|_, inv| inv.room_id != room_id);\n-        self.content.remove(&scope);\n-        self.forum_threads.retain(|(s, _), _| s != &scope);\n-        self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);\n-\n-        let mut to_drop: Vec = self\n-            .ingests_by_id\n-            .iter()\n-            .filter(|(_, ing)| ing.room_id.trim() == room_id)\n-            .map(|(id, _)| id.clone())\n-            .collect();\n-        to_drop.sort();\n-        to_drop.dedup();\n-        for id in to_drop {\n-            if let Some(ing) = self.ingests_by_id.remove(&id) {\n-                self.posts_by_actor\n-                    .entry(ing.principal)\n-                    .and_modify(|q| {\n-                        q.retain(|x| x != &id);\n-                    });\n-            }\n-            self.ingests_ordered.retain(|x| x != &id);\n-            self.redacted_posts.remove(&id);\n-            self.post_redact_ts.remove(&id);\n-        }\n-    }\n-\n-    pub fn apply_event(&mut self, event: Event) {\n-        match event {\n-            Event::UserRegistered(ur) => {\n-                self.users_by_provider.insert(\n-                    (ur.provider.to_lowercase(), ur.provider_id.clone()),\n-                    ur.username,\n-                );\n-            }\n-            Event::TokenIssued(ti) => {\n-                self.tokens_by_id.insert(\n-                    ti.token_id.clone(),\n-                    (ti.username, ti.salt.clone(), ti.token_hash.clone()),\n-                );\n-            }\n-            Event::AgentBound(ab) => {\n-                if ab.agent.is_empty() {\n-                    return;\n-                }\n-                self.agent_bindings.insert(ab.agent, ab.username);\n-            }\n-            Event::RoomCreated(rc) => {\n-                self.rooms.insert(rc.room_id.clone());\n-                self.room_timeline\n-                    .entry(rc.room_id.clone())\n-                    .or_default()\n-                    .push(RoomTimelineEntry {\n-                        ts: rc.ts,\n-                        kind: RoomTimelineKind::RoomCreated {\n-                            owner: rc.owner.clone(),\n-                            slug: rc.slug.clone(),\n-                        },\n-                    });\n-            }\n-            Event::RoomDeleted(rd) => {\n-                let room_id = rd.room_id.clone();\n-                if self.rooms.contains(&room_id) {\n-                    self.room_timeline\n-                        .entry(room_id.clone())\n-                        .or_default()\n-                        .push(RoomTimelineEntry {\n-                            ts: rd.ts,\n-                            kind: RoomTimelineKind::RoomDeleted {\n-                                deleted_by: rd.deleted_by.clone(),\n-                            },\n-                        });\n-                }\n-                self.purge_private_room(&room_id);\n-            }\n-            Event::Ingest(mut ing) => {\n-                ing.thread_tag = canonicalize_tag(&ing.thread_tag);\n-                let room_key = ing.room_id.trim().to_string();\n-                let scope = scope_from_room_wire(&room_key);\n-                let canonical_thread = ing.thread_tag.clone();\n-                let scope_thread_key = (scope.clone(), canonical_thread.clone());\n-\n-                {\n-                    let content = self.content_for_scope_mut(scope.clone());\n-                    if Self::apply_ingest_to_content(content, &ing).is_err() {\n-                        eprintln!(\n-                            \"WARNING: Skipping malformed ingest event {}: parse failed\",\n-                            ing.id\n-                        );\n-                        return;\n-                    }\n-                }\n-\n-                self.ingests_by_id.insert(ing.id.clone(), ing.clone());\n-\n-                let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();\n-                let prev_ts = ft.last_activity_ts;\n-                if ing.ts > prev_ts {\n-                    ft.last_activity_ts = ing.ts;\n-                    ft.last_actor = ing.principal.clone();\n-                }\n-\n-                nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));\n-\n-                nav!(self.ingests_ordered, push_back(ing.id.clone()));\n-\n-                nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));\n-\n-                nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));\n-            }\n-            Event::PostRedacted(pr) => {\n-                self.redacted_posts.insert(pr.post_id.clone());\n-                self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);\n-                let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {\n-                    return;\n-                };\n-                let scope = scope_from_room_wire(ing.room_id.trim());\n-                // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.\n-                self.rebuild_scope_content(scope);\n-            }\n-            Event::GrantAdded(ga) => {\n-                let room_id = ga.room_id.clone();\n-                let caps = self.grants\n-                    .entry(ga.room_id)\n-                    .or_default()\n-                    .entry(ga.username.clone())\n-                    .or_default();\n-                for cap in ga.capabilities.iter().copied() {\n-                    caps.insert(cap);\n-                }\n-                self.room_timeline\n-                    .entry(room_id)\n-                    .or_default()\n-                    .push(RoomTimelineEntry {\n-                        ts: ga.ts,\n-                        kind: RoomTimelineKind::GrantAdded {\n-                            username: ga.username.clone(),\n-                            granted_by: ga.granted_by.clone(),\n-                            capabilities: ga.capabilities.clone(),\n-                        },\n-                    });\n-            }\n-            Event::GrantRevoked(gr) => {\n-                let room_id = gr.room_id.clone();\n-                if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {\n-                    let username = gr.username.clone();\n-                    if let Some(caps) = room_grants.get_mut(&username) {\n-                        for cap in &gr.capabilities {\n-                            caps.remove(cap);\n-                        }\n-                        if caps.is_empty() {\n-                            room_grants.remove(&username);\n-                        }\n-                    }\n-                    if room_grants.is_empty() {\n-                        self.grants.remove(&gr.room_id);\n-                    }\n-                }\n-                self.room_timeline\n-                    .entry(room_id)\n-                    .or_default()\n-                    .push(RoomTimelineEntry {\n-                        ts: gr.ts,\n-                        kind: RoomTimelineKind::GrantRevoked {\n-                            username: gr.username.clone(),\n-                            revoked_by: gr.revoked_by.clone(),\n-                            capabilities: gr.capabilities.clone(),\n-                        },\n-                    });\n-            }\n-            Event::InviteMinted(im) => {\n-                self.invites.insert(\n-                    im.token.clone(),\n-                    ActiveInviteState {\n-                        room_id: im.room_id.clone(),\n-                        capabilities: im.capabilities.iter().copied().collect(),\n-                        inviter: im.inviter.clone(),\n-                        uses_remaining: im.max_uses,\n-                        expires_ts_ms: im.expires_ts_ms,\n-                    },\n-                );\n-            }\n-            Event::InviteRedeemed(ir) => {\n-                if let Some(inv) = self.invites.get_mut(&ir.token) {\n-                    inv.uses_remaining = inv.uses_remaining.saturating_sub(1);\n-                    if inv.uses_remaining == 0 {\n-                        self.invites.remove(&ir.token);\n-                    }\n-                }\n-            }\n-        }\n-    }\n-}\n-\n-impl Default for ReducerState {\n-    fn default() -> Self {\n-        let mut content = HashMap::new();\n-        content.insert(ScopeId::Public, ContentState::default());\n-        Self {\n-            content,\n-            users_by_provider: HashMap::new(),\n-            tokens_by_id: HashMap::new(),\n-            agent_bindings: HashMap::new(),\n-            ingests_by_id: HashMap::new(),\n-            ingests_by_scope_thread: HashMap::new(),\n-            rooms: HashSet::new(),\n-            forum_threads: HashMap::new(),\n-            actor_last_post_ts: HashMap::new(),\n-            ingests_ordered: Vec::new(),\n-            posts_by_actor: HashMap::new(),\n-            redacted_posts: HashSet::new(),\n-            post_redact_ts: HashMap::new(),\n-            grants: HashMap::new(),\n-            room_timeline: HashMap::new(),\n-            invites: HashMap::new(),\n-        }\n-    }\n-}\n-\ndiff --git a/scripts/clj-test.sh b/scripts/clj-test.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952\n--- /dev/null\n+++ b/scripts/clj-test.sh\n@@ -0,0 +1,5 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+cd \"$(dirname \"$0\")/..\"\n+mkdir -p target\n+exec clojure -M:kaocha\ndiff --git a/seed.tdsl b/seed.tdsl\ndeleted file mode 100644\nindex 0b707179f09781c53d6526cffeb9158579698ad7..0000000000000000000000000000000000000000\n--- a/seed.tdsl\n+++ /dev/null\n@@ -1,23 +0,0 @@\n-reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social?\n-jsonl based. Same stack as slug\n-fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval\n-Viewcounters for every site\n-Login with Reddit oauth?\n-Streaks? Streak gates? Progressive revelation?\n-One pair of the day\n-Use attributes again, for assholery. To distinguish from reddits single dimension\n-r/sorter_south_park_characters \n-Is a one shot category that only has the characters.\n-subs starting with sorter have special priority in sorter index.\n-\n-layer above reddit?\n-\n-\n-use websocket based bar i had built in afterlife... \n-\n-\n-claude {{\n-What I'd push on hardest: what's the artifact that gets shared? Letterboxd works because every movie has a beautiful URL, every user has a beautiful profile, every list is screenshot-worthy. AITA-but-ranked needs the same. \"This person ranks 97th percentile of asshole, beating 4,891 other Reddit posters this month\" — presented well, that's a tweet, that's a screenshot, that's how the next thousand users find you. Build the share artifact early. Streaks and progressive revelation are for retention; the share-out is for acquisition; you need both but the acquisition piece is what makes streaks worth designing.\n-}}\n-\n-have index be sorted by viewcount.\n\\ No newline at end of file\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..7906a8547d56b8e6a48ef59c37aa82a8510fdee9\n--- /dev/null\n+++ b/server/Cargo.toml\n@@ -0,0 +1,22 @@\n+[package]\n+name = \"sorter2-server\"\n+version = \"0.0.1\"\n+edition = \"2021\"\n+license = \"MIT\"\n+\n+[dependencies]\n+axum = { version = \"0.7\", features = [\"macros\"] }\n+axum-extra = { version = \"0.9\", features = [\"cookie\"] }\n+tokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"signal\", \"fs\", \"io-util\"] }\n+serde = { version = \"1\", features = [\"derive\"] }\n+serde_json = \"1\"\n+thiserror = \"1\"\n+maud = { version = \"0.26\", features = [\"axum\"] }\n+tower = \"0.5\"\n+tower-http = { version = \"0.5\", features = [\"trace\"] }\n+tracing = \"0.1\"\n+tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n+\n+[dev-dependencies]\n+reqwest = { version = \"0.12\", features = [\"json\"] }\n+tempfile = \"3\"\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..07e2856593f1dd98219b5022d223aca62885292f\n--- /dev/null\n+++ b/server/src/api/mod.rs\n@@ -0,0 +1 @@\n+pub mod ui_html;\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..de7cafd8f5eac60eb9675362818ea37531ac6ba9\n--- /dev/null\n+++ b/server/src/api/ui_html.rs\n@@ -0,0 +1,71 @@\n+use axum::{\n+    extract::State,\n+    response::{IntoResponse, Response},\n+    Form,\n+};\n+use std::collections::HashMap;\n+\n+use crate::{\n+    html::{demo_counter_panel, JsBuilder},\n+    state::AppState,\n+    ui_action::{parse_html_ui_from_form, HtmlUiAction},\n+};\n+\n+fn ui_js_warn(msg: &str) -> Response {\n+    let js = format!(\"console.warn({});\", crate::html::js_string_literal(msg));\n+    axum::response::Response::builder()\n+        .status(axum::http::StatusCode::OK)\n+        .header(\n+            axum::http::header::CONTENT_TYPE,\n+            \"text/javascript; charset=utf-8\",\n+        )\n+        .body(axum::body::Body::from(js))\n+        .unwrap()\n+}\n+\n+pub async fn post_ui_html(\n+    State(state): State,\n+    Form(form): Form>,\n+) -> impl IntoResponse {\n+    let action = match parse_html_ui_from_form(&form) {\n+        Ok(a) => a,\n+        Err(e) => return ui_js_warn(&e.to_string()).into_response(),\n+    };\n+\n+    match action {\n+        HtmlUiAction::BumpDemoCounter => {\n+            let count = state.bump_demo_counter().await;\n+            let panel = demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref());\n+            JsBuilder::new()\n+                .morph_selector(\"#demo-counter-panel\", panel)\n+                .into_response()\n+        }\n+    }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+    use crate::ui_action::{HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};\n+\n+    #[test]\n+    fn parse_error_is_warn_not_panic() {\n+        let form = HashMap::new();\n+        let err = parse_html_ui_from_form(&form).unwrap_err();\n+        assert!(matches!(err, HtmlUiParseError::MissingRpc));\n+    }\n+\n+    #[test]\n+    fn bump_action_deserializes() {\n+        let template = serde_json::json!({ \"action\": \"bump_demo_counter\" });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        assert_eq!(\n+            parse_html_ui_from_form(&form).unwrap(),\n+            HtmlUiAction::BumpDemoCounter\n+        );\n+    }\n+}\ndiff --git a/server/src/event_log.rs b/server/src/event_log.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..03c86f7f028cdf167b4943951d724da486b1b82c\n--- /dev/null\n+++ b/server/src/event_log.rs\n@@ -0,0 +1,80 @@\n+use std::path::{Path, PathBuf};\n+\n+use tokio::{\n+    fs::{self, OpenOptions},\n+    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},\n+};\n+\n+use crate::events::Event;\n+\n+#[derive(Debug, thiserror::Error)]\n+pub enum EventLogError {\n+    #[error(\"io error: {0}\")]\n+    Io(#[from] std::io::Error),\n+    #[error(\"json error: {0}\")]\n+    Json(#[from] serde_json::Error),\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct EventLog {\n+    path: PathBuf,\n+}\n+\n+impl EventLog {\n+    pub fn new(path: impl Into) -> Self {\n+        Self { path: path.into() }\n+    }\n+\n+    pub fn path(&self) -> &Path {\n+        &self.path\n+    }\n+\n+    pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {\n+        if let Some(parent) = self.path.parent() {\n+            fs::create_dir_all(parent).await?;\n+        }\n+        Ok(())\n+    }\n+\n+    pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {\n+        self.ensure_parent_dir().await?;\n+        let mut f: tokio::fs::File = OpenOptions::new()\n+            .create(true)\n+            .append(true)\n+            .open(&self.path)\n+            .await?;\n+\n+        let mut line = serde_json::to_string(event)?;\n+        line.push('\\n');\n+        f.write_all(line.as_bytes()).await?;\n+        f.flush().await?;\n+        Ok(())\n+    }\n+\n+    pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+        if !fs::try_exists(&self.path).await? {\n+            return Ok((vec![], vec![]));\n+        }\n+\n+        let f = fs::File::open(&self.path).await?;\n+        let mut reader = BufReader::new(f).lines();\n+\n+        let mut events = Vec::new();\n+        let mut bad_lines = Vec::new();\n+\n+        let mut line_no: usize = 0;\n+        while let Some(line) = reader.next_line().await? {\n+            line_no += 1;\n+            let trimmed = line.trim();\n+            if trimmed.is_empty() {\n+                continue;\n+            }\n+            match serde_json::from_str::(trimmed) {\n+                Ok(ev) => events.push(ev),\n+                Err(_) => bad_lines.push((line_no, line)),\n+            }\n+        }\n+\n+        Ok((events, bad_lines))\n+    }\n+}\ndiff --git a/server/src/events.rs b/server/src/events.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0e8d004c9fabe4007ffef45cfcacafb00f4c229d\n--- /dev/null\n+++ b/server/src/events.rs\n@@ -0,0 +1,10 @@\n+use serde::{Deserialize, Serialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+#[serde(tag = \"type\", rename_all = \"snake_case\")]\n+pub enum Event {\n+    /// Page view recorded (path → counter in views.json).\n+    ViewRecorded { path: String, ts: i64 },\n+    /// Demo counter bump from `POST /ui` (persisted in the single JSONL log).\n+    DemoCounterBumped { ts: i64, value: u64 },\n+}\ndiff --git a/server/src/form_template.rs b/server/src/form_template.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..b9bc3982a125e94e67c99175ea9055979541abba\n--- /dev/null\n+++ b/server/src/form_template.rs\n@@ -0,0 +1,118 @@\n+use serde::Serialize;\n+use serde_json::Value;\n+use std::collections::HashMap;\n+\n+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.\n+pub fn template_json_compact(v: &T) -> serde_json::Result {\n+    serde_json::to_string(v)\n+}\n+\n+/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n+/// string for `key` (empty if missing). Other keys are unchanged.\n+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n+    match val {\n+        Value::Object(map) => {\n+            if map.len() == 1 {\n+                if let Some(Value::String(field_name)) = map.get(\"$form\") {\n+                    let submitted = form_data\n+                        .get(field_name.as_str())\n+                        .map(|s| s.as_str())\n+                        .unwrap_or(\"\");\n+                    *val = Value::String(submitted.to_string());\n+                    return;\n+                }\n+            }\n+            for v in map.values_mut() {\n+                substitute_form_vars(v, form_data);\n+            }\n+        }\n+        Value::Array(arr) => {\n+            for v in arr.iter_mut() {\n+                substitute_form_vars(v, form_data);\n+            }\n+        }\n+        _ => {}\n+    }\n+}\n+\n+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.\n+pub fn fill_template_from_form(\n+    template_json: &str,\n+    form_data: &HashMap,\n+) -> Result {\n+    let mut v: Value = serde_json::from_str(template_json)?;\n+    substitute_form_vars(&mut v, form_data);\n+    Ok(v)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+    use serde::Deserialize;\n+\n+    #[derive(Debug, Deserialize, PartialEq, Eq)]\n+    struct Demo {\n+        room: String,\n+        thread_tag: String,\n+        nested: Nested,\n+    }\n+\n+    #[derive(Debug, Deserialize, PartialEq, Eq)]\n+    struct Nested {\n+        text: String,\n+    }\n+\n+    #[test]\n+    fn holes_become_strings() {\n+        let json = r#\"{\n+            \"room\": \"public\",\n+            \"thread_tag\": {\"$form\": \"tag\"},\n+            \"nested\": {\"text\": {\"$form\": \"body\"}}\n+        }\"#;\n+        let mut form = HashMap::new();\n+        form.insert(\"tag\".into(), \"foo\".into());\n+        form.insert(\"body\".into(), \"hello\\nworld\".into());\n+\n+        let v = fill_template_from_form(json, &form).unwrap();\n+        let d: Demo = serde_json::from_value(v).unwrap();\n+        assert_eq!(\n+            d,\n+            Demo {\n+                room: \"public\".into(),\n+                thread_tag: \"foo\".into(),\n+                nested: Nested {\n+                    text: \"hello\\nworld\".into(),\n+                },\n+            }\n+        );\n+    }\n+\n+    #[test]\n+    fn missing_form_key_is_empty_string() {\n+        let json = r#\"{\"x\": {\"$form\": \"nope\"}}\"#;\n+        let mut form = HashMap::new();\n+        form.insert(\"other\".into(), \"y\".into());\n+        let v = fill_template_from_form(json, &form).unwrap();\n+        assert_eq!(v[\"x\"], \"\");\n+    }\n+\n+    #[test]\n+    fn array_of_holes() {\n+        let json = r#\"{\"items\": [{\"$form\": \"a\"}, {\"$form\": \"b\"}]}\"#;\n+        let mut form = HashMap::new();\n+        form.insert(\"a\".into(), \"1\".into());\n+        form.insert(\"b\".into(), \"2\".into());\n+        let v = fill_template_from_form(json, &form).unwrap();\n+        assert_eq!(v[\"items\"], serde_json::json!([\"1\", \"2\"]));\n+    }\n+\n+    #[test]\n+    fn template_json_compact_escapes_and_single_line() {\n+        let s = template_json_compact(&serde_json::json!({\n+            \"x\": \"quote\\\"and\\nnewline\"\n+        }))\n+        .unwrap();\n+        assert!(!s.contains('\\n'));\n+        assert!(s.contains(\"\\\\\\\"\") || s.contains(\"\\\\n\"));\n+    }\n+}\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..b10773272173b9cc37cbb3983fb2315a7bfed5ea\n--- /dev/null\n+++ b/server/src/html/mod.rs\n@@ -0,0 +1,234 @@\n+use axum::{\n+    body::Body,\n+    extract::{Path, State},\n+    http::{header, HeaderValue, StatusCode, Uri},\n+    response::{IntoResponse, Response},\n+    Form,\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::{html, Markup, DOCTYPE};\n+use serde::Deserialize;\n+\n+use crate::{\n+    form_template::template_json_compact,\n+    state::AppState,\n+    ui_action::UI_RPC_FIELD,\n+};\n+\n+const THEME_DEFAULT_CSS: &str = include_str!(\"../../static/theme_default.css\");\n+const THEME_RETRO_CSS: &str = include_str!(\"../../static/theme_retro.css\");\n+const SORTER_UI_JS: &str = include_str!(\"../../static/sorter_ui.js\");\n+\n+pub const SORTER_THEME_COOKIE: &str = \"sorter-theme\";\n+\n+pub fn normalize_theme(raw: &str) -> &'static str {\n+    match raw {\n+        \"retro\" => \"retro\",\n+        _ => \"default\",\n+    }\n+}\n+\n+pub fn theme_from_jar(jar: &CookieJar) -> &'static str {\n+    jar.get(SORTER_THEME_COOKIE)\n+        .map(|c| normalize_theme(c.value()))\n+        .unwrap_or(\"default\")\n+}\n+\n+pub fn theme_next_from_uri(uri: &Uri) -> String {\n+    uri.path_and_query()\n+        .map(|pq| pq.as_str().to_string())\n+        .filter(|s| !s.is_empty())\n+        .unwrap_or_else(|| \"/\".to_string())\n+}\n+\n+pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {\n+    let t = normalize_theme(theme);\n+    let s = format!(\"{SORTER_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000\");\n+    HeaderValue::from_str(&s).expect(\"theme cookie must be ASCII\")\n+}\n+\n+fn sanitize_theme_next(next: Option<&str>) -> String {\n+    let s = next.unwrap_or(\"/\").trim();\n+    if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n+        s.to_string()\n+    } else {\n+        \"/\".to_string()\n+    }\n+}\n+\n+#[derive(Debug, Deserialize)]\n+pub struct ThemeForm {\n+    theme: String,\n+    next: Option,\n+}\n+\n+pub async fn post_theme(Form(form): Form) -> impl IntoResponse {\n+    let theme = normalize_theme(&form.theme);\n+    let next = sanitize_theme_next(form.next.as_deref());\n+    let loc =\n+        HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n+    Response::builder()\n+        .status(StatusCode::SEE_OTHER)\n+        .header(header::LOCATION, loc)\n+        .header(header::SET_COOKIE, theme_cookie_header_value(theme))\n+        .body(Body::empty())\n+        .expect(\"theme redirect response\")\n+}\n+\n+pub async fn serve_static(Path(filename): Path) -> impl IntoResponse {\n+    if filename == \"sorter_ui.js\" {\n+        return Response::builder()\n+            .status(StatusCode::OK)\n+            .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+            .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n+            .body(SORTER_UI_JS.to_string())\n+            .unwrap()\n+            .into_response();\n+    }\n+\n+    let theme = filename\n+        .strip_prefix(\"theme_\")\n+        .and_then(|s| s.strip_suffix(\".css\"));\n+\n+    let css = match theme {\n+        Some(\"default\") => THEME_DEFAULT_CSS,\n+        Some(\"retro\") => THEME_RETRO_CSS,\n+        _ => return (StatusCode::NOT_FOUND, \"static file not found\").into_response(),\n+    };\n+\n+    Response::builder()\n+        .status(StatusCode::OK)\n+        .header(header::CONTENT_TYPE, \"text/css; charset=utf-8\")\n+        .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n+        .body(css.to_string())\n+        .unwrap()\n+        .into_response()\n+}\n+\n+pub(crate) fn js_string_literal(s: &str) -> String {\n+    serde_json::to_string(s).expect(\"javascript string escaping\")\n+}\n+\n+pub(crate) struct JsBuilder {\n+    snippets: Vec,\n+}\n+\n+impl JsBuilder {\n+    pub(crate) fn new() -> Self {\n+        Self {\n+            snippets: Vec::new(),\n+        }\n+    }\n+\n+    pub(crate) fn morph_selector(mut self, selector: &str, markup: Markup) -> Self {\n+        let html = js_string_literal(&markup.into_string());\n+        self.snippets.push(format!(\n+            \"var __el = document.querySelector({sel}); if (__el) {{ Idiomorph.morph(__el, {html}); }}\",\n+            sel = js_string_literal(selector),\n+        ));\n+        self\n+    }\n+\n+    pub(crate) fn build(self) -> String {\n+        self.snippets.join(\" \")\n+    }\n+\n+    pub(crate) fn into_response(self) -> Response {\n+        Response::builder()\n+            .status(StatusCode::OK)\n+            .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+            .body(Body::from(self.build()))\n+            .unwrap()\n+    }\n+}\n+\n+pub fn now_ms() -> i64 {\n+    let t = std::time::SystemTime::now()\n+        .duration_since(std::time::UNIX_EPOCH)\n+        .unwrap_or_default();\n+    t.as_millis() as i64\n+}\n+\n+fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str) -> Markup {\n+    let css_href = format!(\"/static/theme_{theme}.css\");\n+    html! {\n+        (DOCTYPE)\n+        html {\n+            head {\n+                meta charset=\"utf-8\";\n+                meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n+                title { (title) }\n+                link rel=\"stylesheet\" href=(css_href) id=\"theme-stylesheet\";\n+                script src=\"https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js\" {}\n+            }\n+            body class=\"home\" {\n+                @if views > 0 {\n+                    span class=\"view-meta muted\" { (views) \" views\" }\n+                }\n+                div id=\"errors\" {}\n+                (body)\n+                div id=\"controls\" {\n+                    a href=\"https://github.com/sortersocial/sorter2\" id=\"src-link\" { \"src\" }\n+                    form id=\"sorter-theme-form\" method=\"post\" action=\"/theme\" data-navigate=\"full\" {\n+                        input type=\"hidden\" name=\"next\" value=(theme_next);\n+                        select id=\"theme-select\" name=\"theme\" onchange=\"this.form.submit()\" aria-label=\"Theme\" {\n+                            @for (val, label) in [(\"default\", \"default\"), (\"retro\", \"retro\")] {\n+                                @if theme == val {\n+                                    option value=(val) selected { (label) }\n+                                } @else {\n+                                    option value=(val) { (label) }\n+                                }\n+                            }\n+                        }\n+                    }\n+                }\n+                script src=\"/static/sorter_ui.js\" {}\n+            }\n+        }\n+    }\n+}\n+\n+pub fn demo_counter_panel(count: u64, event_log_path: &str) -> Markup {\n+    let rpc = template_json_compact(&serde_json::json!({ \"action\": \"bump_demo_counter\" }))\n+        .expect(\"rpc json\");\n+    html! {\n+        section id=\"demo-counter-panel\" class=\"demo-panel\" {\n+            h1 { \"sorter2\" }\n+            p class=\"muted\" {\n+                \"Pairwise ranking over Reddit — seed scaffold. \"\n+                \"Click bumps a counter persisted to the JSONL event log.\"\n+            }\n+            p class=\"demo-count\" {\n+                strong { \"Counter: \" }\n+                span id=\"demo-count-value\" { (count) }\n+            }\n+            p class=\"muted small\" {\n+                \"Event log: \" code { (event_log_path) }\n+            }\n+            form method=\"post\" action=\"/ui\" id=\"demo-bump-form\" {\n+                input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n+                button type=\"submit\" class=\"btn-primary\" { \"Bump (POST /ui → eval JS)\" }\n+            }\n+            p class=\"muted small\" {\n+                \"Uses hidden \"\n+                code { \"__rpc__\" }\n+                \" JSON + Idiomorph morph — no full page reload.\"\n+            }\n+        }\n+    }\n+}\n+\n+pub async fn home(\n+    State(state): State,\n+    jar: CookieJar,\n+    uri: Uri,\n+) -> impl IntoResponse {\n+    let path = uri.path().to_string();\n+    state.views.increment(path.clone());\n+    let views = state.views.get_views(&path);\n+    let count = *state.demo_counter.read().await;\n+    let theme = theme_from_jar(&jar);\n+    let theme_next = theme_next_from_uri(&uri);\n+    let body = demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref());\n+    layout(\"sorter2\", body, views, theme, &theme_next)\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..19976e549168e5305c96f490ec31e6d705a54ba2\n--- /dev/null\n+++ b/server/src/lib.rs\n@@ -0,0 +1,53 @@\n+pub mod api;\n+pub mod event_log;\n+pub mod events;\n+pub mod form_template;\n+pub mod html;\n+pub mod path_types;\n+pub mod ranking;\n+pub mod reducer;\n+pub mod state;\n+pub mod ui_action;\n+pub mod views;\n+\n+use axum::{\n+    routing::{get, post},\n+    Router,\n+};\n+use tower_http::trace::TraceLayer;\n+\n+use crate::state::{AppConfig, AppState};\n+\n+pub async fn create_app_state(cfg: AppConfig) -> AppState {\n+    AppState::new(cfg).await\n+}\n+\n+pub fn create_app(state: AppState) -> Router {\n+    Router::new()\n+        .route(\"/healthz\", get(|| async { \"ok\" }))\n+        .route(\"/static/:filename\", get(crate::html::serve_static))\n+        .route(\"/\", get(crate::html::home))\n+        .route(\"/ui\", post(crate::api::ui_html::post_ui_html))\n+        .route(\"/theme\", post(crate::html::post_theme))\n+        .with_state(state)\n+        .layer(TraceLayer::new_for_http())\n+}\n+\n+pub async fn run(cfg: AppConfig) -> Result<(), Box> {\n+    let state = create_app_state(cfg.clone()).await;\n+    let app = create_app(state);\n+\n+    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], cfg.port));\n+    tracing::info!(\"listening on http://{addr}\");\n+    let listener = tokio::net::TcpListener::bind(addr).await?;\n+    axum::serve(listener, app)\n+        .with_graceful_shutdown(shutdown_signal())\n+        .await?;\n+    Ok(())\n+}\n+\n+async fn shutdown_signal() {\n+    tokio::signal::ctrl_c()\n+        .await\n+        .expect(\"failed to install CTRL+C handler\");\n+}\ndiff --git a/server/src/main.rs b/server/src/main.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c22ec6c9f5358e5ec99fb83210dc351938505a93\n--- /dev/null\n+++ b/server/src/main.rs\n@@ -0,0 +1,15 @@\n+use sorter2_server::state::AppConfig;\n+\n+#[tokio::main]\n+async fn main() -> Result<(), Box> {\n+    tracing_subscriber::fmt()\n+        .with_env_filter(\n+            tracing_subscriber::EnvFilter::try_from_default_env()\n+                .unwrap_or_else(|_| \"sorter2_server=info,tower_http=info\".into()),\n+        )\n+        .init();\n+\n+    let cfg = AppConfig::from_env();\n+    std::fs::create_dir_all(&cfg.data_dir)?;\n+    sorter2_server::run(cfg).await\n+}\ndiff --git a/server/src/path_types.rs b/server/src/path_types.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..1cdc96a25b1954f11aaf955203e2b9907b578366\n--- /dev/null\n+++ b/server/src/path_types.rs\n@@ -0,0 +1,30 @@\n+use serde::{Deserialize, Serialize};\n+use std::fmt;\n+\n+/// Stable item key for votes and rankings (opaque string for now).\n+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]\n+pub struct ItemId(String);\n+\n+impl ItemId {\n+    pub fn parse(s: &str) -> Option {\n+        let t = s.trim();\n+        if t.is_empty() {\n+            return None;\n+        }\n+        Some(Self(t.to_string()))\n+    }\n+\n+    pub fn opaque(s: impl Into) -> Self {\n+        Self(s.into())\n+    }\n+\n+    pub fn as_str(&self) -> &str {\n+        &self.0\n+    }\n+}\n+\n+impl fmt::Display for ItemId {\n+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+        f.write_str(&self.0)\n+    }\n+}\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..89d3280126a8d8f841721ce8cb63ff735d68752a\n--- /dev/null\n+++ b/server/src/ranking.rs\n@@ -0,0 +1,388 @@\n+use std::collections::{HashMap, HashSet};\n+\n+use crate::path_types::ItemId;\n+use crate::reducer::GroupState;\n+\n+#[derive(Debug, Clone)]\n+pub struct RankedItem {\n+    pub item: ItemId,\n+    pub score: f64,\n+}\n+\n+/// Compute connected components over the voted-pairs graph (treated as undirected).\n+///\n+/// Returns:\n+/// - `components`: each component is a sorted list of node indices, excluding isolates.\n+/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).\n+pub fn connected_components_from_voted_pairs(\n+    n: usize,\n+    voted_pairs: impl Iterator,\n+) -> (Vec>, Vec) {\n+    let mut adj: Vec> = vec![Vec::new(); n];\n+    for (a, b) in voted_pairs {\n+        if a >= n || b >= n || a == b {\n+            continue;\n+        }\n+        adj[a].push(b);\n+        adj[b].push(a);\n+    }\n+\n+    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();\n+    isolates.sort();\n+\n+    let mut seen = vec![false; n];\n+    for &i in &isolates {\n+        seen[i] = true;\n+    }\n+\n+    let mut comps: Vec> = Vec::new();\n+    for i in 0..n {\n+        if seen[i] {\n+            continue;\n+        }\n+        let mut stack = vec![i];\n+        seen[i] = true;\n+        let mut comp: Vec = Vec::new();\n+        while let Some(x) = stack.pop() {\n+            comp.push(x);\n+            for &y in &adj[x] {\n+                if !seen[y] {\n+                    seen[y] = true;\n+                    stack.push(y);\n+                }\n+            }\n+        }\n+        comp.sort();\n+        comps.push(comp);\n+    }\n+\n+    (comps, isolates)\n+}\n+\n+/// Compute rank centrality scores for a GroupState.\n+/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing\n+/// an O(E) multiply per iteration.\n+pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {\n+    if !group.dirty && !group.cached_scores.is_empty() {\n+        return;\n+    }\n+\n+    let n = group.idx_to_item.len();\n+    if n == 0 {\n+        group.cached_scores = vec![];\n+        group.dirty = false;\n+        return;\n+    }\n+    if n == 1 {\n+        group.cached_scores = vec![1.0];\n+        group.dirty = false;\n+        return;\n+    }\n+\n+    let scores = compute_scores_from_edges(\n+        n,\n+        group.edges.iter().map(|(&k, &w)| (k, w)),\n+        max_iters,\n+        tol,\n+    );\n+    group.cached_scores = scores;\n+    group.dirty = false;\n+}\n+\n+pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {\n+    compute_group_ranking(group, max_iters, tol);\n+    let mut items: Vec = group\n+        .idx_to_item\n+        .iter()\n+        .enumerate()\n+        .map(|(i, item)| RankedItem {\n+            item: item.clone(),\n+            score: *group.cached_scores.get(i).unwrap_or(&0.0),\n+        })\n+        .collect();\n+\n+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+    items\n+}\n+\n+fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {\n+    if n == 0 {\n+        return vec![];\n+    }\n+    if n == 1 {\n+        return vec![1.0];\n+    }\n+\n+    // Collect raw edges into a map for pairwise normalization.\n+    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();\n+    for ((src, dst), w) in edges {\n+        if src >= n || dst >= n || w <= 0.0 {\n+            continue;\n+        }\n+        *raw.entry((src, dst)).or_insert(0.0) += w;\n+    }\n+\n+    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).\n+    // This ensures repeated votes on the same pair don't inflate influence\n+    // beyond what the ratio implies.\n+    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();\n+    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();\n+    for (i, j) in keys {\n+        if normalized.contains_key(&(i, j)) {\n+            continue;\n+        }\n+        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);\n+        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);\n+        let total = w_ij + w_ji;\n+        if total <= 0.0 {\n+            continue;\n+        }\n+        normalized.insert((i, j), w_ij / total);\n+        if w_ji > 0.0 {\n+            normalized.insert((j, i), w_ji / total);\n+        }\n+    }\n+\n+    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):\n+    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared\n+    //   P_ii = 1 - (1/d_max) * Σ_k A_ik\n+    // where d_i is the *degree* (number of distinct neighbors compared) and\n+    // d_max = max_i d_i. Using the unweighted degree — not the sum of\n+    // pairwise-normalized weights — is what guarantees aperiodicity: it\n+    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree\n+    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+    // loss). Without this, regular comparison graphs (e.g. a pure star at\n+    // ratio 2:1) produce a bipartite chain that oscillates instead of\n+    // converging — see issue #146.\n+    let mut out_edges: Vec> = vec![Vec::new(); n];\n+    let mut neighbors: Vec> = vec![HashSet::new(); n];\n+\n+    for ((src, dst), w) in &normalized {\n+        out_edges[*src].push((*dst, *w));\n+        neighbors[*src].insert(*dst);\n+        neighbors[*dst].insert(*src);\n+    }\n+\n+    let weight_sum: Vec = out_edges\n+        .iter()\n+        .map(|es| es.iter().map(|(_, w)| *w).sum())\n+        .collect();\n+    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+    if d_max == 0 {\n+        return vec![1.0 / n as f64; n];\n+    }\n+    let d_max_f = d_max as f64;\n+\n+    let mut scores = vec![1.0 / n as f64; n];\n+    let mut next = vec![0.0f64; n];\n+\n+    for _ in 0..max_iters {\n+        next.fill(0.0);\n+        for i in 0..n {\n+            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;\n+            next[i] += scores[i] * stay_prob;\n+\n+            if out_edges[i].is_empty() {\n+                continue;\n+            }\n+            for &(dst, w) in &out_edges[i] {\n+                next[dst] += scores[i] * (w / d_max_f);\n+            }\n+        }\n+\n+        let diff: f64 = scores\n+            .iter()\n+            .zip(next.iter())\n+            .map(|(a, b)| (a - b).abs())\n+            .sum();\n+\n+        scores.clone_from_slice(&next);\n+        if diff < tol {\n+            break;\n+        }\n+    }\n+\n+    let sum: f64 = scores.iter().sum();\n+    if sum.is_finite() && sum > 0.0 {\n+        for s in &mut scores {\n+            *s /= sum;\n+        }\n+    }\n+    scores\n+}\n+\n+/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.\n+///\n+/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.\n+pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {\n+    if idxs.is_empty() {\n+        return vec![];\n+    }\n+\n+    // Map original idx -> compact idx [0..m)\n+    let mut map: HashMap = HashMap::with_capacity(idxs.len());\n+    for (j, &i) in idxs.iter().enumerate() {\n+        map.insert(i, j);\n+    }\n+\n+    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {\n+        let s = *map.get(&src)?;\n+        let d = *map.get(&dst)?;\n+        Some(((s, d), w))\n+    });\n+\n+    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);\n+\n+    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).\n+    let mut items: Vec = idxs\n+        .iter()\n+        .enumerate()\n+        .filter_map(|(j, &orig)| {\n+            let item = group.idx_to_item.get(orig)?.clone();\n+            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })\n+        })\n+        .collect();\n+\n+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+    items\n+}\n+\n+pub fn group_summary_scores(\n+    group: &mut GroupState,\n+    max_iters: usize,\n+    tol: f64,\n+) -> HashMap {\n+    ranked_items(group, max_iters, tol)\n+        .into_iter()\n+        .map(|r| (r.item, r.score))\n+        .collect()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+    use crate::reducer::VoteData;\n+\n+    fn mk_group() -> GroupState {\n+        GroupState::new()\n+    }\n+\n+    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+        use crate::path_types::ItemId;\n+        VoteData {\n+            ts,\n+            a: ItemId::parse(a).unwrap(),\n+            b: ItemId::parse(b).unwrap(),\n+            ratio_left: l,\n+            ratio_right: r,\n+            body: \"because\".to_string(),\n+            principal: \"test\".to_string(),\n+            delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n+            thread_tag: \"untagged\".to_string(),\n+        }\n+    }\n+\n+    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n+    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+    /// chain was bipartite; power iteration oscillated and returned the\n+    /// uniform initial distribution after an even number of steps. Using the\n+    /// paper's degree-based d_max gives every node a positive self-loop and\n+    /// the chain converges to the correct stationary distribution.\n+    #[test]\n+    fn star_topology_winner_at_top_via_subset() {\n+        let mut g = mk_group();\n+        g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+        g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+        let mut items: Vec<(usize, String)> = g\n+            .idx_to_item\n+            .iter()\n+            .enumerate()\n+            .map(|(i, it)| (i, it.as_str().to_string()))\n+            .collect();\n+        items.sort_by(|a, b| a.1.cmp(&b.1));\n+        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+        for r in &ranked {\n+            eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+        }\n+        assert_eq!(\n+            ranked[0].item.as_str(),\n+            \"zebra\",\n+            \"zebra won both votes and should rank #1\"\n+        );\n+    }\n+\n+    #[test]\n+    fn group_ranking_cache_dirty_flow() {\n+        let mut g = mk_group();\n+        assert!(g.dirty);\n+\n+        g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+        assert!(g.dirty);\n+        assert!(g.cached_scores.is_empty());\n+\n+        compute_group_ranking(&mut g, 10000, 1e-8);\n+        assert!(!g.dirty);\n+        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());\n+\n+        // Recomputing when not dirty should be a no-op.\n+        let before = g.cached_scores.clone();\n+        compute_group_ranking(&mut g, 10000, 1e-8);\n+        assert_eq!(before, g.cached_scores);\n+    }\n+\n+    #[test]\n+    fn connected_components_split_disconnected_pairs() {\n+        let mut g = mk_group();\n+        // Two disconnected edges: (a,b) and (c,d)\n+        g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+        g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n+\n+        let n = g.idx_to_item.len();\n+        let (mut comps, isolates) =\n+            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());\n+        assert!(isolates.is_empty());\n+        // Order-independent: sort components by their item names for stable assert.\n+        comps.sort_by_key(|c| {\n+            c.iter()\n+                .map(|&i| g.idx_to_item[i].clone())\n+                .collect::>()\n+        });\n+        assert_eq!(comps.len(), 2);\n+        let comp0 = comps[0]\n+            .iter()\n+            .map(|&i| g.idx_to_item[i].as_str())\n+            .collect::>();\n+        let comp1 = comps[1]\n+            .iter()\n+            .map(|&i| g.idx_to_item[i].as_str())\n+            .collect::>();\n+        assert_eq!(comp0, vec![\"a\", \"b\"]);\n+        assert_eq!(comp1, vec![\"c\", \"d\"]);\n+    }\n+\n+    #[test]\n+    fn subset_ranking_ranks_within_component_only() {\n+        let mut g = mk_group();\n+        g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n+        g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n+\n+        let (comps, _) =\n+            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());\n+        assert_eq!(comps.len(), 2);\n+\n+        // Rank each component and ensure winner is first within that component.\n+        for comp in comps {\n+            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);\n+            assert_eq!(ranked.len(), 2);\n+            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();\n+            if names.contains(&\"a\") {\n+                assert_eq!(names[0], \"a\");\n+            } else {\n+                assert_eq!(names[0], \"d\");\n+            }\n+        }\n+    }\n+}\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..933cfa2043997476ee81f78c3508e885908cfa24\n--- /dev/null\n+++ b/server/src/reducer.rs\n@@ -0,0 +1,94 @@\n+use std::collections::{HashMap, HashSet, VecDeque};\n+\n+use serde::{Deserialize, Serialize};\n+\n+use crate::path_types::ItemId;\n+\n+/// Parsed pairwise vote (internal representation).\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct VoteData {\n+    pub ts: i64,\n+    pub a: ItemId,\n+    pub b: ItemId,\n+    pub ratio_left: i32,\n+    pub ratio_right: i32,\n+    pub body: String,\n+    pub principal: String,\n+    pub delegate: Option,\n+    pub thread_tag: String,\n+}\n+\n+#[derive(Debug, Clone, Default)]\n+pub struct GroupState {\n+    pub item_to_idx: HashMap,\n+    pub idx_to_item: Vec,\n+    pub edges: HashMap<(usize, usize), f64>,\n+    pub voted_pairs: HashSet<(usize, usize)>,\n+    pub dirty: bool,\n+    pub cached_scores: Vec,\n+    pub recent_votes: VecDeque,\n+}\n+\n+impl GroupState {\n+    pub fn new() -> Self {\n+        Self {\n+            item_to_idx: HashMap::new(),\n+            idx_to_item: Vec::new(),\n+            edges: HashMap::new(),\n+            voted_pairs: HashSet::new(),\n+            dirty: true,\n+            cached_scores: Vec::new(),\n+            recent_votes: VecDeque::with_capacity(200),\n+        }\n+    }\n+\n+    fn ensure_item(&mut self, item: &ItemId) -> usize {\n+        if let Some(&idx) = self.item_to_idx.get(item) {\n+            return idx;\n+        }\n+        let idx = self.idx_to_item.len();\n+        self.idx_to_item.push(item.clone());\n+        self.item_to_idx.insert(item.clone(), idx);\n+        self.dirty = true;\n+        idx\n+    }\n+\n+    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n+        if w <= 0.0 {\n+            return;\n+        }\n+        *self.edges.entry((src, dst)).or_insert(0.0) += w;\n+        self.dirty = true;\n+    }\n+\n+    pub fn apply_vote(&mut self, mut vote: VoteData) {\n+        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n+        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n+        if vote.ratio_left < 0 {\n+            vote.ratio_left = 0;\n+        }\n+        if vote.ratio_right < 0 {\n+            vote.ratio_right = 0;\n+        }\n+        let a_idx = self.ensure_item(&vote.a);\n+        let b_idx = self.ensure_item(&vote.b);\n+\n+        let (i, j) = if a_idx < b_idx {\n+            (a_idx, b_idx)\n+        } else {\n+            (b_idx, a_idx)\n+        };\n+        self.voted_pairs.insert((i, j));\n+\n+        let w_a = vote.ratio_left as f64;\n+        let w_b = vote.ratio_right as f64;\n+\n+        self.add_edge_weight(b_idx, a_idx, w_a);\n+        self.add_edge_weight(a_idx, b_idx, w_b);\n+\n+        self.recent_votes.push_front(vote);\n+        while self.recent_votes.len() > 200 {\n+            self.recent_votes.pop_back();\n+        }\n+    }\n+}\ndiff --git a/server/src/state.rs b/server/src/state.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d57a8c2b447312c8079b62e3f55f661ccc5e8447\n--- /dev/null\n+++ b/server/src/state.rs\n@@ -0,0 +1,76 @@\n+use std::sync::Arc;\n+\n+use tokio::sync::RwLock;\n+\n+use crate::{event_log::EventLog, events::Event, views::ViewStore};\n+\n+#[derive(Clone)]\n+pub struct AppConfig {\n+    pub data_dir: String,\n+    pub event_log_path: String,\n+    pub port: u16,\n+}\n+\n+impl AppConfig {\n+    pub fn from_env() -> Self {\n+        let data_dir = std::env::var(\"SORTER2_DATA_DIR\").unwrap_or_else(|_| \"./data\".into());\n+        let event_log_path = std::env::var(\"SORTER2_EVENT_LOG\")\n+            .unwrap_or_else(|_| format!(\"{data_dir}/events.jsonl\"));\n+        let port = std::env::var(\"PORT\")\n+            .ok()\n+            .and_then(|p| p.parse().ok())\n+            .unwrap_or(8080);\n+        Self {\n+            data_dir,\n+            event_log_path,\n+            port,\n+        }\n+    }\n+}\n+\n+#[derive(Clone)]\n+pub struct AppState {\n+    pub cfg: Arc,\n+    pub event_log: Arc,\n+    pub views: ViewStore,\n+    pub demo_counter: Arc>,\n+}\n+\n+impl AppState {\n+    pub async fn new(cfg: AppConfig) -> Self {\n+        let event_log = Arc::new(EventLog::new(cfg.event_log_path.clone()));\n+        let views_path = format!(\"{}/views.json\", cfg.data_dir);\n+        let views = ViewStore::new(&views_path);\n+\n+        let mut demo_counter: u64 = 0;\n+        if let Ok((events, _)) = event_log.load_all().await {\n+            for ev in events {\n+                if let Event::DemoCounterBumped { value, .. } = ev {\n+                    demo_counter = demo_counter.max(value);\n+                }\n+            }\n+        }\n+\n+        Self {\n+            cfg: Arc::new(cfg),\n+            event_log,\n+            views,\n+            demo_counter: Arc::new(RwLock::new(demo_counter)),\n+        }\n+    }\n+\n+    pub async fn bump_demo_counter(&self) -> u64 {\n+        let mut guard = self.demo_counter.write().await;\n+        *guard += 1;\n+        let value = *guard;\n+        drop(guard);\n+\n+        let ts = crate::html::now_ms();\n+        let _ = self\n+            .event_log\n+            .append(&Event::DemoCounterBumped { ts, value })\n+            .await;\n+\n+        value\n+    }\n+}\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..68f04d59e24575d69903c0366e1cdb49b3bf903e\n--- /dev/null\n+++ b/server/src/ui_action.rs\n@@ -0,0 +1,55 @@\n+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).\n+\n+use crate::form_template::fill_template_from_form;\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+use std::collections::HashMap;\n+use thiserror::Error;\n+\n+pub const UI_RPC_FIELD: &str = \"__rpc__\";\n+\n+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[serde(tag = \"action\", rename_all = \"snake_case\")]\n+pub enum HtmlUiAction {\n+    /// Demo: morph `#demo-counter-panel` after bumping the persisted counter.\n+    BumpDemoCounter,\n+}\n+\n+#[derive(Debug, Error)]\n+pub enum HtmlUiParseError {\n+    #[error(\"missing __rpc__ field\")]\n+    MissingRpc,\n+    #[error(\"invalid template json: {0}\")]\n+    Template(serde_json::Error),\n+    #[error(\"invalid ui action: {0}\")]\n+    Action(serde_json::Error),\n+}\n+\n+pub fn parse_html_ui_from_form(\n+    form: &HashMap,\n+) -> Result {\n+    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;\n+    let mut hole_map = form.clone();\n+    hole_map.remove(UI_RPC_FIELD);\n+    let v: Value =\n+        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n+    serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+\n+    #[test]\n+    fn bump_demo_counter_round_trip() {\n+        let template = serde_json::json!({ \"action\": \"bump_demo_counter\" });\n+        let mut form = HashMap::new();\n+        form.insert(\n+            UI_RPC_FIELD.to_string(),\n+            serde_json::to_string(&template).unwrap(),\n+        );\n+        let a = parse_html_ui_from_form(&form).unwrap();\n+        assert_eq!(a, HtmlUiAction::BumpDemoCounter);\n+    }\n+}\ndiff --git a/server/src/views.rs b/server/src/views.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c5d94777d7f0bf0249cb0033fd013eb0af773904\n--- /dev/null\n+++ b/server/src/views.rs\n@@ -0,0 +1,62 @@\n+use std::{\n+    collections::HashMap,\n+    sync::{Arc, Mutex},\n+};\n+use tokio::sync::mpsc;\n+\n+type CountMap = Arc>>;\n+\n+#[derive(Clone)]\n+pub struct ViewStore {\n+    counts: CountMap,\n+    flush_tx: mpsc::Sender<()>,\n+}\n+\n+impl ViewStore {\n+    pub fn new(json_path: &str) -> Self {\n+        let initial: HashMap = std::fs::read_to_string(json_path)\n+            .ok()\n+            .and_then(|s| serde_json::from_str(&s).ok())\n+            .unwrap_or_default();\n+\n+        let counts: CountMap = Arc::new(Mutex::new(initial));\n+        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);\n+        let path = json_path.to_string();\n+\n+        let counts_for_writer = counts.clone();\n+        tokio::spawn(async move {\n+            while flush_rx.recv().await.is_some() {\n+                while flush_rx.try_recv().is_ok() {}\n+\n+                let snapshot: HashMap = {\n+                    counts_for_writer.lock().unwrap().clone()\n+                };\n+\n+                let path = path.clone();\n+                let _ = tokio::task::spawn_blocking(move || {\n+                    if let Ok(json) = serde_json::to_string(&snapshot) {\n+                        let tmp = format!(\"{path}.tmp\");\n+                        if std::fs::write(&tmp, &json).is_ok() {\n+                            let _ = std::fs::rename(&tmp, &path);\n+                        }\n+                    }\n+                })\n+                .await;\n+            }\n+        });\n+\n+        Self { counts, flush_tx }\n+    }\n+\n+    pub fn increment(&self, path: String) {\n+        {\n+            let mut map = self.counts.lock().unwrap();\n+            *map.entry(path).or_insert(0) += 1;\n+        }\n+        let _ = self.flush_tx.try_send(());\n+    }\n+\n+    pub fn get_views(&self, path: &str) -> u64 {\n+        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)\n+    }\n+}\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5cdf32836faf4b06930bcf048cf8cb1b1c1e574e\n--- /dev/null\n+++ b/server/static/sorter_ui.js\n@@ -0,0 +1,34 @@\n+/**\n+ * sorter2 web UI: fetch/eval for POST /ui. No product logic here.\n+ */\n+(function () {\n+  function evalJs(js) {\n+    if (js && String(js).trim()) {\n+      eval(js);\n+    }\n+  }\n+\n+  function initSorterUi() {\n+    document.addEventListener('submit', async function (e) {\n+      var f = e.target;\n+      if (!f || f.tagName !== 'FORM') return;\n+      if ((f.method || 'get').toLowerCase() !== 'post') return;\n+      if (f.id === 'sorter-theme-form') return;\n+      if (f.getAttribute('data-navigate') === 'full') return;\n+      e.preventDefault();\n+      var resp = await fetch(f.action, {\n+        method: 'POST',\n+        body: new URLSearchParams(new FormData(f)),\n+        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n+        credentials: 'same-origin',\n+      });\n+      evalJs(await resp.text());\n+    });\n+  }\n+\n+  if (document.readyState === 'loading') {\n+    document.addEventListener('DOMContentLoaded', initSorterUi);\n+  } else {\n+    initSorterUi();\n+  }\n+})();\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..a4ea4d6c4d3af918257365150555515f0bb8b944\n--- /dev/null\n+++ b/server/static/theme_default.css\n@@ -0,0 +1,87 @@\n+:root {\n+  --bg: #0f1115;\n+  --fg: #e8eaed;\n+  --muted: #9aa0a6;\n+  --accent: #7cacf8;\n+  --panel: #1a1d24;\n+  --border: #2a2f3a;\n+}\n+\n+* {\n+  box-sizing: border-box;\n+}\n+\n+body {\n+  margin: 0;\n+  font-family: system-ui, -apple-system, sans-serif;\n+  background: var(--bg);\n+  color: var(--fg);\n+  line-height: 1.5;\n+}\n+\n+.muted {\n+  color: var(--muted);\n+}\n+\n+.small {\n+  font-size: 0.875rem;\n+}\n+\n+.view-meta {\n+  position: fixed;\n+  top: 0.5rem;\n+  right: 0.5rem;\n+  font-size: 0.75rem;\n+}\n+\n+#controls {\n+  position: fixed;\n+  bottom: 0;\n+  left: 0;\n+  right: 0;\n+  display: flex;\n+  gap: 1rem;\n+  align-items: center;\n+  padding: 0.75rem 1rem;\n+  background: var(--panel);\n+  border-top: 1px solid var(--border);\n+  font-size: 0.875rem;\n+}\n+\n+#controls a {\n+  color: var(--accent);\n+}\n+\n+.demo-panel {\n+  max-width: 40rem;\n+  margin: 4rem auto 6rem;\n+  padding: 2rem;\n+  background: var(--panel);\n+  border: 1px solid var(--border);\n+  border-radius: 8px;\n+}\n+\n+.demo-panel h1 {\n+  margin-top: 0;\n+}\n+\n+.btn-primary {\n+  background: var(--accent);\n+  color: #0f1115;\n+  border: none;\n+  padding: 0.5rem 1rem;\n+  border-radius: 4px;\n+  font-size: 1rem;\n+  cursor: pointer;\n+}\n+\n+.btn-primary:hover {\n+  filter: brightness(1.1);\n+}\n+\n+code {\n+  font-size: 0.85em;\n+  background: var(--bg);\n+  padding: 0.1em 0.35em;\n+  border-radius: 3px;\n+}\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..ceee99494b873a6e658857be97f02ee739db85e9\n--- /dev/null\n+++ b/server/static/theme_retro.css\n@@ -0,0 +1,5 @@\n+@import url(\"/static/theme_default.css\");\n+\n+:root {\n+  --accent: #f4a261;\n+}\ndiff --git a/server/tests/integration_health.rs b/server/tests/integration_health.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e7940d4e40a9be61b113403752f78d69ae18628f\n--- /dev/null\n+++ b/server/tests/integration_health.rs\n@@ -0,0 +1,54 @@\n+use std::net::SocketAddr;\n+\n+use axum::Router;\n+use sorter2_server::{create_app, create_app_state, state::AppConfig};\n+use tempfile::TempDir;\n+use tokio::net::TcpListener;\n+\n+async fn start_test_server() -> (SocketAddr, TempDir) {\n+    let tmp = TempDir::new().unwrap();\n+    let data = tmp.path().to_string_lossy().into_owned();\n+    let cfg = AppConfig {\n+        data_dir: data.clone(),\n+        event_log_path: format!(\"{data}/events.jsonl\"),\n+        port: 0,\n+    };\n+    let state = create_app_state(cfg).await;\n+    let app: Router = create_app(state);\n+\n+    let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n+    let addr = listener.local_addr().unwrap();\n+    tokio::spawn(async move {\n+        axum::serve(listener, app).await.unwrap();\n+    });\n+    (addr, tmp)\n+}\n+\n+#[tokio::test]\n+async fn healthz_ok() {\n+    let (addr, _tmp) = start_test_server().await;\n+    let client = reqwest::Client::new();\n+    let resp = client\n+        .get(format!(\"http://{addr}/healthz\"))\n+        .send()\n+        .await\n+        .unwrap();\n+    assert!(resp.status().is_success());\n+    assert_eq!(resp.text().await.unwrap(), \"ok\");\n+}\n+\n+#[tokio::test]\n+async fn home_has_demo_panel() {\n+    let (addr, _tmp) = start_test_server().await;\n+    let client = reqwest::Client::new();\n+    let html = client\n+        .get(format!(\"http://{addr}/\"))\n+        .send()\n+        .await\n+        .unwrap()\n+        .text()\n+        .await\n+        .unwrap();\n+    assert!(html.contains(\"demo-counter-panel\"));\n+    assert!(html.contains(\"__rpc__\"));\n+}\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..538868371c6e88c3bc7ae83d931039a168fc404f\n--- /dev/null\n+++ b/server/tests/integration_ui.rs\n@@ -0,0 +1,53 @@\n+use std::collections::HashMap;\n+use std::net::SocketAddr;\n+\n+use axum::Router;\n+use sorter2_server::{create_app, create_app_state, state::AppConfig, ui_action::UI_RPC_FIELD};\n+use tempfile::TempDir;\n+use tokio::net::TcpListener;\n+\n+async fn start_test_server() -> (SocketAddr, TempDir) {\n+    let tmp = TempDir::new().unwrap();\n+    let data = tmp.path().to_string_lossy().into_owned();\n+    let cfg = AppConfig {\n+        data_dir: data.clone(),\n+        event_log_path: format!(\"{data}/events.jsonl\"),\n+        port: 0,\n+    };\n+    let state = create_app_state(cfg).await;\n+    let app: Router = create_app(state);\n+\n+    let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n+    let addr = listener.local_addr().unwrap();\n+    tokio::spawn(async move {\n+        axum::serve(listener, app).await.unwrap();\n+    });\n+    (addr, tmp)\n+}\n+\n+#[tokio::test]\n+async fn post_ui_bump_returns_javascript_morph() {\n+    let (addr, tmp) = start_test_server().await;\n+    let rpc = serde_json::json!({ \"action\": \"bump_demo_counter\" }).to_string();\n+    let mut form = HashMap::new();\n+    form.insert(UI_RPC_FIELD.to_string(), rpc);\n+\n+    let client = reqwest::Client::new();\n+    let body = client\n+        .post(format!(\"http://{addr}/ui\"))\n+        .form(&form)\n+        .send()\n+        .await\n+        .unwrap()\n+        .text()\n+        .await\n+        .unwrap();\n+\n+    assert!(body.contains(\"Idiomorph.morph\"));\n+    assert!(body.contains(\"demo-counter-panel\"));\n+    assert!(body.contains(\"Counter:\"));\n+\n+    let log_path = tmp.path().join(\"events.jsonl\");\n+    let log = std::fs::read_to_string(log_path).unwrap();\n+    assert!(log.contains(\"demo_counter_bumped\"));\n+}\ndiff --git a/test/smoke.clj b/test/smoke.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..ae3e778383b76db49245bd8240c17be5292097cd\n--- /dev/null\n+++ b/test/smoke.clj\n@@ -0,0 +1,55 @@\n+(ns test.smoke\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+\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}\n+                                  \"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+(deftest http-smoke-against-running-server\n+  (testing \"build, start, healthz, home contains RPC demo\"\n+    (let [root (repo-root)\n+          data-dir (.getAbsolutePath (doto (io/file (System/getProperty \"java.io.tmpdir\")\n+                                                   (str \"sorter2-smoke-\" (System/currentTimeMillis)))\n+                                     (.mkdirs)))\n+          port (pick-port)\n+          base (str \"http://127.0.0.1:\" port)\n+          bin (str root \"/target/release/sorter2-server\")]\n+      (is (zero? (:exit (process/shell {:dir root}\n+                                      \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n+          \"release build succeeds\")\n+      (is (.exists (io/file bin)) \"binary exists\")\n+      (let [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+        (try\n+          (is (wait-health base 15000) \"server responds to /healthz\")\n+          (let [home (:out (process/shell {:out :string :err :string}\n+                                         \"curl\" \"-sf\" (str base \"/\")))]\n+            (is (str/includes? home \"demo-counter-panel\"))\n+            (is (str/includes? home \"__rpc__\")))\n+          (finally\n+            (process/destroy proc)))))))\ndiff --git a/tests.edn b/tests.edn\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..9dcdfa1c922b3adb054c0480f37d411df8b2b6a1\n--- /dev/null\n+++ b/tests.edn\n@@ -0,0 +1,11 @@\n+#kaocha/v1\n+{:tests\n+ [{:id :all\n+   :test-paths [\"test\"]\n+   :source-paths [\".\"]\n+   :ns-patterns [\"^test\\\\..+\"]\n+   :kaocha.filter/skip-meta [:skip]\n+   :parallel? false}]\n+ :plugins [:kaocha.plugin/junit-xml]\n+ :kaocha.plugin.junit-xml/target-file \"target/kaocha-junit.xml\"\n+ :reporter kaocha.report.progress/report}\ndiff --git a/ui_action.rs b/ui_action.rs\ndeleted file mode 100644\nindex 5e4131dd346a6f80bfe091a9b0cf7902721bc47f..0000000000000000000000000000000000000000\n--- a/ui_action.rs\n+++ /dev/null\n@@ -1,270 +0,0 @@\n-//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).\n-//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).\n-\n-use crate::form_template::fill_template_from_form;\n-use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n-use std::collections::HashMap;\n-use thiserror::Error;\n-\n-/// Form field name for the compact JSON template (possibly with `{\"$form\":\"…\"}` holes).\n-pub const UI_RPC_FIELD: &str = \"__rpc__\";\n-\n-fn default_ui_form_action() -> String {\n-    \"/ui\".to_string()\n-}\n-\n-/// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n-#[serde(tag = \"action\", rename_all = \"snake_case\")]\n-pub enum HtmlUiAction {\n-    /// Forum ingest via `POST /ui`.\n-    PostIngest {\n-        room: String,\n-        thread_tag: String,\n-        text: String,\n-        #[serde(default)]\n-        error_target: Option,\n-        #[serde(default)]\n-        form_id: Option,\n-    },\n-    /// DSL check / validation via `POST /ui`.\n-    CheckIngest {\n-        room: String,\n-        thread_tag: String,\n-        text: String,\n-        #[serde(default)]\n-        error_target: Option,\n-        #[serde(default)]\n-        form_id: Option,\n-    },\n-    /// Post a pairwise vote from `/vote` (browser compose).\n-    VoteComparePost {\n-        room: String,\n-        thread_tag: String,\n-        left_item: String,\n-        right_item: String,\n-        /// From form fields (string); parsed server-side.\n-        ratio_left: String,\n-        ratio_right: String,\n-        explanation: String,\n-        /// Same-origin path after successful post (JS redirect).\n-        next: String,\n-        /// Pool parent item path, if the vote was initiated from a pool URL.\n-        #[serde(default)]\n-        pool: Option,\n-        #[serde(default = \"default_ui_form_action\")]\n-        form_action: String,\n-    },\n-    /// Set or clear the garden HUD pin cookie (`slug_garden_pin`). Response is **`303` + `Set-Cookie`** when submitted as a full-navigation form (`data-navigate=\"full\"`), matching `POST /theme`.\n-    SetGardenPin {\n-        #[serde(default)]\n-        clear: bool,\n-        #[serde(default)]\n-        room_wire: String,\n-        #[serde(default)]\n-        item_storage: Option,\n-        next: String,\n-        /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.\n-        #[serde(default = \"default_ui_form_action\")]\n-        form_action: String,\n-    },\n-    /// Resolve children or siblings for an external garden item.\n-    ResolveExternal {\n-        room_wire: String,\n-        item_storage: String,\n-        mode: String,\n-        next: String,\n-        #[serde(default = \"default_ui_form_action\")]\n-        form_action: String,\n-    },\n-    /// Author redacts own post via `POST /ui`.\n-    RedactPost { post_id: String },\n-    /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n-    SetRoomMembersExpanded {\n-        room_wire: String,\n-        #[serde(default)]\n-        expanded: bool,\n-    },\n-    /// Delete the private room (Manage only); redirects to `/` on success.\n-    DeleteRoom { room: String },\n-    /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n-    SetNewThreadComposeExpanded {\n-        room_wire: String,\n-        #[serde(default)]\n-        expanded: bool,\n-    },\n-    /// Replace a truncated post card with the full body (same thread index).\n-    ExpandPostFull {\n-        room: String,\n-        thread_tag: String,\n-        post_index: usize,\n-    },\n-    /// Expand a redacted/tombstone post to show stripped body (author view).\n-    ExpandRedactedPost {\n-        room: String,\n-        thread_tag: String,\n-        post_index: usize,\n-    },\n-    /// Collapse an expanded redacted post back to the tombstone card.\n-    CollapseRedactedPost {\n-        room: String,\n-        thread_tag: String,\n-        post_index: usize,\n-    },\n-    /// Copy full thread text (CLI `forum show` format) to the clipboard.\n-    CopyThread {\n-        room: String,\n-        thread_tag: String,\n-        copy_btn_id: String,\n-    },\n-}\n-\n-#[derive(Debug, Error)]\n-pub enum HtmlUiParseError {\n-    #[error(\"missing __rpc__ field\")]\n-    MissingRpc,\n-    #[error(\"invalid template json: {0}\")]\n-    Template(serde_json::Error),\n-    #[error(\"invalid ui action: {0}\")]\n-    Action(serde_json::Error),\n-}\n-\n-/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.\n-pub fn parse_html_ui_from_form(\n-    form: &HashMap,\n-) -> Result {\n-    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;\n-    let mut hole_map = form.clone();\n-    hole_map.remove(UI_RPC_FIELD);\n-    let v: Value =\n-        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n-    serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n-}\n-\n-#[cfg(test)]\n-mod tests {\n-    use super::*;\n-\n-    #[test]\n-    fn round_trip_post_ingest_with_holes() {\n-        let template = serde_json::json!({\n-            \"action\": \"post_ingest\",\n-            \"room\": \"public\",\n-            \"thread_tag\": {\"$form\": \"thread_tag\"},\n-            \"text\": {\"$form\": \"text\"},\n-            \"error_target\": \"e\",\n-            \"form_id\": \"f\",\n-        });\n-        let mut form = HashMap::new();\n-        form.insert(\n-            UI_RPC_FIELD.to_string(),\n-            serde_json::to_string(&template).unwrap(),\n-        );\n-        form.insert(\"thread_tag\".into(), \"x\".into());\n-        form.insert(\"text\".into(), \"body\".into());\n-\n-        let a = parse_html_ui_from_form(&form).unwrap();\n-        assert_eq!(\n-            a,\n-            HtmlUiAction::PostIngest {\n-                room: \"public\".into(),\n-                thread_tag: \"x\".into(),\n-                text: \"body\".into(),\n-                error_target: Some(\"e\".into()),\n-                form_id: Some(\"f\".into()),\n-            }\n-        );\n-    }\n-\n-    #[test]\n-    fn expand_post_full_round_trip() {\n-        let template = serde_json::json!({\n-            \"action\": \"expand_post_full\",\n-            \"room\": \"public\",\n-            \"thread_tag\": \"demo\",\n-            \"post_index\": 3,\n-        });\n-        let mut form = HashMap::new();\n-        form.insert(\n-            UI_RPC_FIELD.to_string(),\n-            serde_json::to_string(&template).unwrap(),\n-        );\n-        let a = parse_html_ui_from_form(&form).unwrap();\n-        assert_eq!(\n-            a,\n-            HtmlUiAction::ExpandPostFull {\n-                room: \"public\".into(),\n-                thread_tag: \"demo\".into(),\n-                post_index: 3,\n-            }\n-        );\n-    }\n-\n-    #[test]\n-    fn set_room_members_expanded_defaults_false() {\n-        let template = serde_json::json!({\n-            \"action\": \"set_room_members_expanded\",\n-            \"room_wire\": \"ab/cd\",\n-        });\n-        let mut form = HashMap::new();\n-        form.insert(\n-            UI_RPC_FIELD.to_string(),\n-            serde_json::to_string(&template).unwrap(),\n-        );\n-        let a = parse_html_ui_from_form(&form).unwrap();\n-        assert_eq!(\n-            a,\n-            HtmlUiAction::SetRoomMembersExpanded {\n-                room_wire: \"ab/cd\".into(),\n-                expanded: false,\n-            }\n-        );\n-    }\n-\n-    #[test]\n-    fn copy_thread_round_trip() {\n-        let template = serde_json::json!({\n-            \"action\": \"copy_thread\",\n-            \"room\": \"public\",\n-            \"thread_tag\": \"demo\",\n-            \"copy_btn_id\": \"thread-copy-top\",\n-        });\n-        let mut form = HashMap::new();\n-        form.insert(\n-            UI_RPC_FIELD.to_string(),\n-            serde_json::to_string(&template).unwrap(),\n-        );\n-        let a = parse_html_ui_from_form(&form).unwrap();\n-        assert_eq!(\n-            a,\n-            HtmlUiAction::CopyThread {\n-                room: \"public\".into(),\n-                thread_tag: \"demo\".into(),\n-                copy_btn_id: \"thread-copy-top\".into(),\n-            }\n-        );\n-    }\n-\n-    #[test]\n-    fn set_new_thread_compose_expanded_true() {\n-        let template = serde_json::json!({\n-            \"action\": \"set_new_thread_compose_expanded\",\n-            \"room_wire\": \"ab/cd\",\n-            \"expanded\": true,\n-        });\n-        let mut form = HashMap::new();\n-        form.insert(\n-            UI_RPC_FIELD.to_string(),\n-            serde_json::to_string(&template).unwrap(),\n-        );\n-        let a = parse_html_ui_from_form(&form).unwrap();\n-        assert_eq!(\n-            a,\n-            HtmlUiAction::SetNewThreadComposeExpanded {\n-                room_wire: \"ab/cd\".into(),\n-                expanded: true,\n-            }\n-        );\n-    }\n-}\ndiff --git a/views.rs b/views.rs\ndeleted file mode 100644\nindex d4f0ffc49475f014698b4da0de6f476884430813..0000000000000000000000000000000000000000\n--- a/views.rs\n+++ /dev/null\n@@ -1,63 +0,0 @@\n-use std::{\n-    collections::HashMap,\n-    sync::{Arc, Mutex},\n-};\n-use tokio::sync::mpsc;\n-\n-type CountMap = Arc>>;\n-\n-#[derive(Clone)]\n-pub struct ViewStore {\n-    counts: CountMap,\n-    flush_tx: mpsc::Sender<()>,\n-}\n-\n-impl ViewStore {\n-    pub fn new(json_path: &str) -> Self {\n-        // Load existing counts from disk on startup (best-effort)\n-        let initial: HashMap = std::fs::read_to_string(json_path)\n-            .ok()\n-            .and_then(|s| serde_json::from_str(&s).ok())\n-            .unwrap_or_default();\n-\n-        let counts: CountMap = Arc::new(Mutex::new(initial));\n-        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);\n-        let path = json_path.to_string();\n-\n-        let counts_for_writer = counts.clone();\n-        tokio::spawn(async move {\n-            while flush_rx.recv().await.is_some() {\n-                while flush_rx.try_recv().is_ok() {}\n-\n-                let snapshot: HashMap = {\n-                    counts_for_writer.lock().unwrap().clone()\n-                };\n-\n-                let path = path.clone();\n-                let _ = tokio::task::spawn_blocking(move || {\n-                    if let Ok(json) = serde_json::to_string(&snapshot) {\n-                        let tmp = format!(\"{path}.tmp\");\n-                        if std::fs::write(&tmp, &json).is_ok() {\n-                            let _ = std::fs::rename(&tmp, &path);\n-                        }\n-                    }\n-                })\n-                .await;\n-            }\n-        });\n-\n-        Self { counts, flush_tx }\n-    }\n-\n-    pub fn increment(&self, path: String) {\n-        {\n-            let mut map = self.counts.lock().unwrap();\n-            *map.entry(path).or_insert(0) += 1;\n-        }\n-        let _ = self.flush_tx.try_send(());\n-    }\n-\n-    pub fn get_views(&self, path: &str) -> u64 {\n-        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)\n-    }\n-}\ndiff --git a/vote.rs b/vote.rs\ndeleted file mode 100644\nindex d0ec78cd675eae284d056fb3b8eaf5cc853d6263..0000000000000000000000000000000000000000\n--- a/vote.rs\n+++ /dev/null\n@@ -1,580 +0,0 @@\n-use axum::{\n-    extract::{Path, Query, State},\n-    http::{HeaderMap, StatusCode, Uri},\n-    response::{Html, IntoResponse},\n-};\n-use axum_extra::extract::cookie::CookieJar;\n-use maud::html;\n-use serde::Deserialize;\n-use serde_json::json;\n-use std::collections::{HashMap, HashSet};\n-\n-use crate::{\n-    api::optional_principal,\n-    canonical_path::canonicalize_tag,\n-    form_template::template_json_compact,\n-    html::{\n-        forum::ThreadNav,\n-        layout_full_bleed_chromeless,\n-        ratio_pct, render_item_body_in_scope,\n-        theme_from_jar, theme_next_from_uri,\n-        user_can_post_room,\n-        ui_action::UI_RPC_FIELD,\n-        JsBuilder,\n-    },\n-    middleware::canonical_view_url,\n-    path_types::ItemId,\n-    reducer::{ContentState, ScopeId},\n-    scope_rank::suggest_next_pair_in_pool,\n-    state::AppState,\n-};\n-\n-use super::{\n-    access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},\n-    item::{item_display_path, login_href_with_next},\n-};\n-\n-fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {\n-    let cands: HashSet = content\n-        .item_threads\n-        .get(a)\n-        .into_iter()\n-        .chain(content.item_threads.get(b))\n-        .flat_map(|s| s.iter().cloned())\n-        .collect();\n-    if cands.is_empty() {\n-        return \"vote\".to_string();\n-    }\n-    let mut v: Vec = cands.into_iter().collect();\n-    v.sort();\n-    canonicalize_tag(&v[0])\n-}\n-\n-/// Canonical unordered pair: lexicographic by storage string (stable edge identity).\n-pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n-    let ac = a.clone().normalized_storage();\n-    let bc = b.clone().normalized_storage();\n-    if ac.as_str() <= bc.as_str() {\n-        (ac, bc)\n-    } else {\n-        (bc, ac)\n-    }\n-}\n-\n-/// All votes whose endpoints are exactly this unordered pair (unsorted).\n-pub(super) fn edge_vote_entries_for_pair(\n-    content: &ContentState,\n-    a: &ItemId,\n-    b: &ItemId,\n-) -> 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-    content\n-        .item_votes\n-        .get(&lo)\n-        .into_iter()\n-        .flat_map(|q| q.iter())\n-        .filter(|v| {\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-}\n-\n-pub(super) fn ratios_for_compare_page(\n-    v: &crate::reducer::VoteData,\n-    page_left: &ItemId,\n-    page_right: &ItemId,\n-) -> (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-pub(super) 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-pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {\n-    let (lo, hi) = canonical_edge_items(a, b);\n-    let lo_s = lo.as_str();\n-    let hi_s = hi.as_str();\n-    content\n-        .item_votes\n-        .get(&lo)\n-        .into_iter()\n-        .flat_map(|q| q.iter())\n-        .filter(|v| {\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-        .count()\n-}\n-\n-fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {\n-    let set: HashSet = content\n-        .item_threads\n-        .get(a)\n-        .into_iter()\n-        .chain(content.item_threads.get(b))\n-        .flat_map(|s| s.iter().cloned())\n-        .collect();\n-    let mut v: Vec = set.into_iter().collect();\n-    v.sort();\n-    v.into_iter().map(|t| canonicalize_tag(&t)).collect()\n-}\n-\n-fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {\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\" {\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 (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-                            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\" 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-                        }\n-                    }\n-                }\n-            }\n-        }\n-    }\n-}\n-\n-/// After a successful vote post: refresh edge history (no in-page preview card).\n-#[allow(clippy::too_many_arguments)]\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-    pool: Option<&ItemId>,\n-    _post_id: &str,\n-    _post_idx: Option,\n-) -> String {\n-    let reduced = state.reduced.read().await;\n-    let content = content_for_garden_view(&reduced, &nav.scope());\n-    let edge_history = vote_edge_history_markup(content, left, right);\n-    let next_pair = suggest_next_vote_pair(content, left, right, pool);\n-    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);\n-    drop(reduced);\n-    JsBuilder::new()\n-        .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n-        .morph_selector(\".vote-compare-nav\", nav_markup)\n-        .build()\n-}\n-\n-pub(super) fn vote_compare_href(\n-    nav: &ThreadNav,\n-    left: &ItemId,\n-    right: &ItemId,\n-    thread_override: Option<&str>,\n-    pool: Option<&ItemId>,\n-) -> String {\n-    let left_dp = left.display_path();\n-    let right_dp = right.display_path();\n-    let left_q = urlencoding::encode(&left_dp);\n-    let right_q = urlencoding::encode(&right_dp);\n-    let mut base = format!(\n-        \"{}/vote?left={}&right={}\",\n-        nav.room_path_prefix_for_vote_compare(),\n-        left_q,\n-        right_q\n-    );\n-    if let Some(t) = thread_override.filter(|s| !s.is_empty()) {\n-        base = format!(\"{}&thread={}\", base, urlencoding::encode(t));\n-    }\n-    if let Some(p) = pool {\n-        let pool_dp = p.display_path();\n-        base = format!(\"{}&pool={}\", base, urlencoding::encode(&pool_dp));\n-    }\n-    base\n-}\n-\n-pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {\n-    let display = ItemId::parse(pool_item_str)\n-        .map(|i| i.display_path())\n-        .unwrap_or_else(|| pool_item_str.to_string());\n-    format!(\n-        \"{}/vote?pool={}\",\n-        nav.room_path_prefix_for_vote_compare(),\n-        urlencoding::encode(&display)\n-    )\n-}\n-\n-fn vote_compare_nav_markup(\n-    nav: &ThreadNav,\n-    next_pair: Option<&(ItemId, ItemId)>,\n-    pool: Option<&ItemId>,\n-) -> maud::Markup {\n-    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));\n-    html! {\n-        div class=\"vote-compare-nav\" {\n-            @if let Some(href) = &next_pair_href {\n-                a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n-            } @else {\n-                span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n-            }\n-        }\n-    }\n-}\n-\n-pub(super) fn suggest_next_vote_pair(\n-    content: &ContentState,\n-    current_left: &ItemId,\n-    current_right: &ItemId,\n-    pool_parent: Option<&ItemId>,\n-) -> Option<(ItemId, ItemId)> {\n-    let pool: Vec = if let Some(parent) = pool_parent {\n-        content\n-            .item_children\n-            .get(parent)\n-            .map(|s| s.iter().cloned().collect())\n-            .unwrap_or_default()\n-    } else if current_left.parent().as_ref().map(|p| p.as_str())\n-        == current_right.parent().as_ref().map(|p| p.as_str())\n-    {\n-        current_left\n-            .parent()\n-            .and_then(|parent| {\n-                content\n-                    .item_children\n-                    .get(&parent.normalized_storage())\n-                    .cloned()\n-            })\n-            .map(|children| children.into_iter().collect())\n-            .unwrap_or_default()\n-    } else {\n-        Vec::new()\n-    };\n-    if pool.len() < 2 {\n-        return None;\n-    }\n-    suggest_next_pair_in_pool(\n-        &content.ranking_group,\n-        &pool,\n-        Some((current_left, current_right)),\n-    )\n-}\n-\n-pub(super) fn vote_compare_item_card(\n-    nav: &ThreadNav,\n-    item: &ItemId,\n-    body: Option<&String>,\n-    side_class: &str,\n-    item_bodies: Option<&HashMap>,\n-) -> maud::Markup {\n-    html! {\n-        div class=(format!(\"vote-compare-side {side_class}\")) {\n-            a class=(format!(\"vote-compare-item {side_class}\")) href=(nav.garden_item_href(item)) {\n-                code { (item_display_path(item.as_str())) }\n-            }\n-            @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n-                div class=\"vote-compare-item-body\" {\n-                    (render_item_body_in_scope(\n-                        body,\n-                        nav.garden_root_url(),\n-                        item_bodies,\n-                    ))\n-                }\n-            } @else {\n-                p class=\"muted vote-compare-item-body-empty\" { \"no body yet\" }\n-            }\n-        }\n-    }\n-}\n-#[derive(Debug, Deserialize)]\n-pub struct VoteCompareQuery {\n-    #[serde(default)]\n-    pub left: Option,\n-    #[serde(default)]\n-    pub right: Option,\n-    #[serde(default)]\n-    pub thread: Option,\n-    #[serde(default)]\n-    pub pool: Option,\n-}\n-\n-/// Public pairwise vote UI — `/vote?left=&right=&thread=`.\n-pub async fn vote_compare_page(\n-    State(state): State,\n-    Query(q): Query,\n-    headers: HeaderMap,\n-    jar: CookieJar,\n-    uri: Uri,\n-) -> impl IntoResponse {\n-    let nav = ThreadNav::public();\n-    vote_compare_inner(state, q, nav, headers, jar, uri).await\n-}\n-\n-pub async fn room_vote_compare_page(\n-    State(state): State,\n-    Path(room_key): Path,\n-    Query(q): Query,\n-    headers: HeaderMap,\n-    jar: CookieJar,\n-    uri: Uri,\n-) -> impl IntoResponse {\n-    let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {\n-        return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n-    };\n-    let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n-        return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n-    };\n-    let reduced = state.reduced.read().await;\n-    let user = optional_principal(&headers, &jar, &reduced);\n-    if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n-        drop(reduced);\n-        return room_not_found_page(&jar, &uri).into_response();\n-    }\n-    if !room_scope_has_garden_content(&reduced, &nav) {\n-        drop(reduced);\n-        return room_not_found_page(&jar, &uri).into_response();\n-    }\n-    drop(reduced);\n-    vote_compare_inner(state, q, nav, headers, jar, uri).await\n-}\n-\n-async fn vote_compare_inner(\n-    state: AppState,\n-    q: VoteCompareQuery,\n-    nav: ThreadNav,\n-    headers: HeaderMap,\n-    jar: CookieJar,\n-    uri: Uri,\n-) -> axum::response::Response {\n-    let pool_id: Option = match q.pool.as_deref() {\n-        Some(p) => match ItemId::parse(p.trim()) {\n-            Some(i) => Some(i.normalized_storage()),\n-            None => return (StatusCode::BAD_REQUEST, \"bad pool item\").into_response(),\n-        },\n-        None => None,\n-    };\n-\n-    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {\n-        (Some(l), Some(r)) => {\n-            let left = match ItemId::parse(l.trim()) {\n-                Some(i) => i.normalized_storage(),\n-                None => return (StatusCode::NOT_FOUND, \"bad left item\").into_response(),\n-            };\n-            let right = match ItemId::parse(r.trim()) {\n-                Some(i) => i.normalized_storage(),\n-                None => return (StatusCode::NOT_FOUND, \"bad right item\").into_response(),\n-            };\n-            if left == right {\n-                return (StatusCode::BAD_REQUEST, \"items must differ\").into_response();\n-            }\n-            (left, right)\n-        }\n-        (None, None) => {\n-            let Some(pool) = pool_id.as_ref() else {\n-                return (StatusCode::BAD_REQUEST, \"provide left+right or pool\").into_response();\n-            };\n-            let reduced = state.reduced.read().await;\n-            let content = content_for_garden_view(&reduced, &nav.scope());\n-            let children: Vec = content\n-                .item_children\n-                .get(pool)\n-                .map(|s| s.iter().cloned().collect())\n-                .unwrap_or_default();\n-            if children.len() < 2 {\n-                drop(reduced);\n-                return (StatusCode::BAD_REQUEST, \"pool has fewer than 2 children to compare\").into_response();\n-            }\n-            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);\n-            drop(reduced);\n-            match pair {\n-                Some(p) => p,\n-                None => return (StatusCode::BAD_REQUEST, \"no pairs available in pool\").into_response(),\n-            }\n-        }\n-        _ => return (StatusCode::BAD_REQUEST, \"provide both left and right, or just pool\").into_response(),\n-    };\n-\n-    let reduced = state.reduced.read().await;\n-    let content = content_for_garden_view(&reduced, &nav.scope());\n-    let viewer = optional_principal(&headers, &jar, &reduced);\n-    let can_post = match &nav.scope() {\n-        ScopeId::Public => viewer.is_some(),\n-        ScopeId::Room(rid) => viewer\n-            .as_ref()\n-            .map(|u| user_can_post_room(&reduced, rid, u))\n-            .unwrap_or(false),\n-    };\n-    let auto_thread = q\n-        .thread\n-        .as_ref()\n-        .map(|t| canonicalize_tag(t))\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);\n-    let left_body = content.item_bodies.get(&left).cloned();\n-    let right_body = content.item_bodies.get(&right).cloned();\n-    let item_bodies_for_cards = content.item_bodies.clone();\n-    let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());\n-    drop(reduced);\n-\n-    let title = format!(\n-        \"vote — {} vs {}\",\n-        item_display_path(left.as_str()),\n-        item_display_path(right.as_str())\n-    );\n-    let next_path = uri\n-        .path_and_query()\n-        .map(|pq| pq.as_str().to_string())\n-        .unwrap_or_else(|| \"/vote\".into());\n-\n-    let rpc_json = template_json_compact(&json!({\n-        \"action\": \"vote_compare_post\",\n-        \"room\": nav.room_wire,\n-        \"thread_tag\": {\"$form\": \"thread_tag\"},\n-        \"left_item\": left.as_str(),\n-        \"right_item\": right.as_str(),\n-        \"ratio_left\": {\"$form\": \"ratio_left\"},\n-        \"ratio_right\": {\"$form\": \"ratio_right\"},\n-        \"explanation\": {\"$form\": \"explanation\"},\n-        \"next\": next_path,\n-        \"pool\": pool_id.as_ref().map(|p| p.as_str()),\n-        \"form_action\": \"/ui\",\n-    }))\n-    .expect(\"vote compare rpc json\");\n-\n-    let body = html! {\n-    section class=\"vote-compare-shell\" {\n-        h2 { \"compare\" }\n-        div class=\"vote-compare-pair\" {\n-            (vote_compare_item_card(\n-                &nav,\n-                &left,\n-                left_body.as_ref(),\n-                \"vote-compare-left\",\n-                Some(&item_bodies_for_cards),\n-            ))\n-            span class=\"vote-compare-vs\" { \"vs\" }\n-            (vote_compare_item_card(\n-                &nav,\n-                &right,\n-                right_body.as_ref(),\n-                \"vote-compare-right\",\n-                Some(&item_bodies_for_cards),\n-            ))\n-        }\n-        (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))\n-        div id=\"vote-edge-history-region\" {\n-            (edge_history)\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-                div class=\"vote-thread-picker\" {\n-                    label class=\"vote-thread-picker-label\" { \"thread\" }\n-                    select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n-                        @if thread_tags.is_empty() {\n-                            option value=\"vote\" selected { \"#vote\" }\n-                        }\n-                        @for t in &thread_tags {\n-                            @if *t == auto_thread {\n-                                option value=(t) selected { \"#\" (t) }\n-                            } @else {\n-                                option value=(t) { \"#\" (t) }\n-                            }\n-                        }\n-                    }\n-                }\n-                input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n-                input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n-                label class=\"vote-compare-slider-label\" {\n-                    span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n-                    input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n-                        aria-valuemin=\"0\" aria-valuemax=\"100\";\n-                    span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n-                }\n-                label class=\"vote-explain-label\" { \"reason (required)\" }\n-                textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n-                div id=\"vote-compare-errors\" {}\n-                p { button type=\"submit\" { \"post vote\" } }\n-            }\n-        } @else {\n-            p class=\"muted\" { a href=(login_href_with_next(&next_path)) { \"log in\" } \" to post this vote.\" }\n-        }\n-    }\n-    };\n-\n-    let url_key = canonical_view_url(&uri);\n-    let view_count = state.views.get_views(&url_key);\n-\n-    let page = layout_full_bleed_chromeless(\n-        &title,\n-        \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen\",\n-        body,\n-        Some(view_count),\n-        theme_from_jar(&jar),\n-        &theme_next_from_uri(&uri),\n-    );\n-    Html(page.into_string()).into_response()\n-}\n-\n","role":"user"}],"model":"openai/gpt-5.2-chat"}