diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..2793eb9c61883eaeb6cc2335eb06b196ae96a0d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/ +.git/ +.cpcache/ +.clj-kondo/ +legacy/ +*.tdsl +test/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..083345813f8760b936291c0d171fc2b4ce743424 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Rust tests + run: cargo test --all + + - uses: DeLaGuardo/setup-clojure@12.5 + with: + cli: "1.12.0.1530" + + - name: Clojure tests (Kaocha) + run: | + mkdir -p target + clojure -M:kaocha diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000000000000000000000000000000000..24c07a3875d6a92e90eff5f67def0a718f5f2f0f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,30 @@ +name: Deploy (Fly.io) + +on: + push: + branches: [main] + paths: + - "server/**" + - "Cargo.toml" + - "Cargo.lock" + - "Dockerfile" + - "fly.toml" + - ".github/workflows/deploy.yml" + workflow_dispatch: {} + +concurrency: + group: fly-deploy-sorter2-main + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy sorter2 + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + run: flyctl deploy --remote-only diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..16de5edb7185b04ef5bc64512814d7dfe2c1f50c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target/ +/.cpcache/ +/.clj-kondo/ +/.lsp/ +*.swp +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..266e876bb7ccbe788beb1d5bd53ad5b45ee5825b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2088 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "fastrand", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "maud" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa" +dependencies = [ + "axum-core", + "http", + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorter2-server" +version = "0.0.1" +dependencies = [ + "axum", + "axum-extra", + "maud", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tower", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..156820ec5151b709a254dafc177da10488fee566 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["server"] +resolver = "2" diff --git a/Dockerfile b/Dockerfile index 9cb07c60cb0da063f747cfbf1b3b876ecb8ba03e..38e4d595f2366b6a6e9405f1e12373b70fac79a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ -# time 0.3.47+ requires Rust 1.88 (edition 2024) -FROM rust:1.88-slim as builder +FROM rust:1.88-slim AS builder WORKDIR /build @@ -7,9 +6,8 @@ RUN apt-get update && \ apt-get install -y pkg-config libssl-dev && \ rm -rf /var/lib/apt/lists/* -# Copy source and build. (Keep it simple to avoid remote build cache oddities.) COPY . . -RUN cargo build --release --package slugsocial-server +RUN cargo build --release --package sorter2-server FROM debian:bookworm-slim @@ -19,16 +17,14 @@ RUN apt-get update && \ WORKDIR /app -COPY --from=builder /build/target/release/slugsocial-server /app/slugsocial-server +COPY --from=builder /build/target/release/sorter2-server /app/sorter2-server -# Create data directory for persistent volume RUN mkdir -p /data -ENV SLUG_DATA_DIR=/data -ENV SLUG_EVENT_LOG=/data/events.jsonl +ENV SORTER2_DATA_DIR=/data +ENV SORTER2_EVENT_LOG=/data/events.jsonl ENV PORT=8080 EXPOSE 8080 -CMD ["/app/slugsocial-server"] - +CMD ["/app/sorter2-server"] diff --git a/bundle.js b/bundle.js deleted file mode 100644 index 8d316f9a57cc7c379fe4bd8e42e092c7eac5d265..0000000000000000000000000000000000000000 --- a/bundle.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here. - */ -(function () { - function evalJs(js) { - if (js && String(js).trim()) { - eval(js); - } - } - - // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie) - - function initSlugUi() { - // POST forms → eval response (except theme + full-navigation forms) - document.addEventListener('submit', async function (e) { - var f = e.target; - if (!f || f.tagName !== 'FORM') return; - if ((f.method || 'get').toLowerCase() !== 'post') return; - if (f.id === 'slug-theme-form') return; - if (f.getAttribute('data-navigate') === 'full') return; - e.preventDefault(); - var resp = await fetch(f.action, { - method: 'POST', - body: new URLSearchParams(new FormData(f)), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - credentials: 'same-origin', - }); - evalJs(await resp.text()); - }); - - // SSE: server-pushed JS - function connectSSE() { - var ssePath = window.location.pathname + window.location.search; - var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath)); - es.onmessage = function (e) { - evalJs(e.data); - }; - es.onerror = function () { - es.close(); - setTimeout(connectSSE, 3000); - }; - } - connectSSE(); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initSlugUi); - } else { - initSlugUi(); - } -})(); - diff --git a/clj-test.sh b/clj-test.sh deleted file mode 100755 index b62a49b98ed60a65a46807b7ad80fa3142f14952..0000000000000000000000000000000000000000 --- a/clj-test.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/.." -mkdir -p target -exec clojure -M:kaocha diff --git a/deps.edn b/deps.edn index 0bf892d44f491cb2313e01ae8a942c3097c52948..e3812b9e0a4f21fe4cbe11fa96688e18a4a62d23 100644 --- a/deps.edn +++ b/deps.edn @@ -1,8 +1,5 @@ {:paths ["." "test"] - :deps {cheshire/cheshire {:mvn/version "5.13.0"} - http-kit/http-kit {:mvn/version "2.8.0"} - babashka/fs {:mvn/version "0.5.32"} - babashka/process {:mvn/version "0.6.25"} + :deps {babashka/process {:mvn/version "0.6.25"} com.blockether/spel {:mvn/version "0.7.11"}} :aliases {:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"} diff --git a/event_log.rs b/event_log.rs deleted file mode 100644 index eaae0d495e43a45d6590603892265a62cc92906e..0000000000000000000000000000000000000000 --- a/event_log.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::path::{Path, PathBuf}; - -use tokio::{ - fs::{self, OpenOptions}, - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, -}; - -use crate::events::Event; - -#[derive(Debug, thiserror::Error)] -pub enum EventLogError { - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("json error: {0}")] - Json(#[from] serde_json::Error), -} - -#[derive(Debug, Clone)] -pub struct EventLog { - path: PathBuf, -} - -impl EventLog { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - pub fn path(&self) -> &Path { - &self.path - } - - pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent).await?; - } - Ok(()) - } - - pub async fn append(&self, event: &Event) -> Result<(), EventLogError> { - self.ensure_parent_dir().await?; - let mut f: tokio::fs::File = OpenOptions::new() - .create(true) - .append(true) - .open(&self.path) - .await?; - - let mut line = serde_json::to_string(event)?; - line.push('\n'); - f.write_all(line.as_bytes()).await?; - f.flush().await?; - Ok(()) - } - - /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`. - pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> { - if !fs::try_exists(&self.path).await? { - return Ok((vec![], vec![])); - } - - let f = fs::File::open(&self.path).await?; - let mut reader = BufReader::new(f).lines(); - - let mut events = Vec::new(); - let mut bad_lines = Vec::new(); - - let mut line_no: usize = 0; - while let Some(line) = reader.next_line().await? { - line_no += 1; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match serde_json::from_str::(trimmed) { - Ok(ev) => events.push(ev), - Err(_) => bad_lines.push((line_no, line)), - } - } - - Ok((events, bad_lines)) - } -} - - diff --git a/fly.toml b/fly.toml index bbb9345e527452db1d87a549213645c195eae5fc..f0c6a39f643c204987debc177234d95a8ef44b65 100644 --- a/fly.toml +++ b/fly.toml @@ -1,14 +1,18 @@ -app = "slugsocial" +app = "sorter2" primary_region = "iad" [build] dockerfile = "Dockerfile" [env] - SLUG_DATA_DIR = "/data" - SLUG_EVENT_LOG = "/data/events.jsonl" + SORTER2_DATA_DIR = "/data" + SORTER2_EVENT_LOG = "/data/events.jsonl" PORT = "8080" +[vm] + memory = "256mb" + cpus = 1 + [[services]] internal_port = 8080 protocol = "tcp" @@ -24,19 +28,17 @@ primary_region = "iad" [services.concurrency] type = "connections" - hard_limit = 1000 - soft_limit = 500 + hard_limit = 250 + soft_limit = 100 [[services.http_checks]] - interval = "10s" + interval = "15s" timeout = "2s" - grace_period = "5s" + grace_period = "10s" method = "GET" path = "/healthz" protocol = "http" - tls_skip_verify = false [[mounts]] - source = "slugsocial_data" + source = "sorter2_data" destination = "/data" - diff --git a/forms.rs b/forms.rs deleted file mode 100644 index d509fd889fde3fed2662ce0a39006b7a51ae762a..0000000000000000000000000000000000000000 --- a/forms.rs +++ /dev/null @@ -1,145 +0,0 @@ -tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs -//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from -//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before** -//! deserializing into a typed struct. -//! -//! # Wire format -//! -//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty -//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string -//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not -//! bespoke encodings. -//! -//! # Power vs flat hidden fields -//! -//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`), -//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects, -//! arrays, and optional fields without inventing a new naming scheme each time. -//! -//! # Security -//! -//! Substitution runs **before** `serde` into your command type. It does not fix -//! authorization: if the client can replace the hidden `__rpc__` value, they can -//! change the command shape unless you validate (signed blob, server-side session -//! context, or treat the blob as hints only). Same threat model as any hidden field. - -use serde::Serialize; -use serde_json::Value; -use std::collections::HashMap; - -/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field. -pub fn template_json_compact(v: &T) -> serde_json::Result { - serde_json::to_string(v) -} - -/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted -/// string for `key` (empty if missing). Other keys are unchanged. -pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) { - match val { - Value::Object(map) => { - if map.len() == 1 { - if let Some(Value::String(field_name)) = map.get("$form") { - let submitted = form_data - .get(field_name.as_str()) - .map(|s| s.as_str()) - .unwrap_or(""); - *val = Value::String(submitted.to_string()); - return; - } - } - for v in map.values_mut() { - substitute_form_vars(v, form_data); - } - } - Value::Array(arr) => { - for v in arr.iter_mut() { - substitute_form_vars(v, form_data); - } - } - _ => {} - } -} - -/// Parse JSON, apply [`substitute_form_vars`], return the mutated value. -pub fn fill_template_from_form( - template_json: &str, - form_data: &HashMap, -) -> Result { - let mut v: Value = serde_json::from_str(template_json)?; - substitute_form_vars(&mut v, form_data); - Ok(v) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - - #[derive(Debug, Deserialize, PartialEq, Eq)] - struct Demo { - room: String, - thread_tag: String, - nested: Nested, - } - - #[derive(Debug, Deserialize, PartialEq, Eq)] - struct Nested { - text: String, - } - - #[test] - fn holes_become_strings() { - let json = r#"{ - "room": "public", - "thread_tag": {"$form": "tag"}, - "nested": {"text": {"$form": "body"}} - }"#; - let mut form = HashMap::new(); - form.insert("tag".into(), "foo".into()); - form.insert("body".into(), "hello\nworld".into()); - - let v = fill_template_from_form(json, &form).unwrap(); - let d: Demo = serde_json::from_value(v).unwrap(); - assert_eq!( - d, - Demo { - room: "public".into(), - thread_tag: "foo".into(), - nested: Nested { - text: "hello\nworld".into(), - }, - } - ); - } - - #[test] - fn missing_form_key_is_empty_string() { - let json = r#"{"x": {"$form": "nope"}}"#; - let mut form = HashMap::new(); - form.insert("other".into(), "y".into()); - let v = fill_template_from_form(json, &form).unwrap(); - assert_eq!(v["x"], ""); - } - - #[test] - fn array_of_holes() { - let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#; - let mut form = HashMap::new(); - form.insert("a".into(), "1".into()); - form.insert("b".into(), "2".into()); - let v = fill_template_from_form(json, &form).unwrap(); - assert_eq!(v["items"], serde_json::json!(["1", "2"])); - } - - #[test] - fn template_json_compact_escapes_and_single_line() { - let s = template_json_compact(&serde_json::json!({ - "x": "quote\"and\nnewline" - })) - .unwrap(); - assert!(!s.contains('\n')); - assert!(s.contains("\\\"") || s.contains("\\n")); - } -} -tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ - diff --git a/gameifying.tdsl b/gameifying.tdsl deleted file mode 100644 index 93d2a61d94fcba4370c82d9612e6bb0f0318cc15..0000000000000000000000000000000000000000 --- a/gameifying.tdsl +++ /dev/null @@ -1,2 +0,0 @@ -consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. -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. diff --git a/ideas/gameifying.tdsl b/ideas/gameifying.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15 --- /dev/null +++ b/ideas/gameifying.tdsl @@ -0,0 +1,2 @@ +consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. +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. diff --git a/ideas/pagerank_streaming.tdsl b/ideas/pagerank_streaming.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a --- /dev/null +++ b/ideas/pagerank_streaming.tdsl @@ -0,0 +1,11 @@ +eventually i want to have ranking histories. +like "visionary" and "fraud" waxing and waning in a uplot graph over time for #elon-musk. +there are too many query combinations to precomupte the ranking histories +(arbitrary user filters, and tag overlap combinations maybe), +so we're just going to have to calculate them all on demand. +computers are fast, its okay. for each vote, we need to calculate rank centrality again. +i was thinking, for n votes we calculate the rank centrality, +and get node weights. we then output that data to the client (over websocket, or testable barrier). +then we calculate n+1 votes, _but we keep the node weights in memory_ +so the rank centrality process converges faster. +this also has the side effect of making the ranking stream in satisfyingly as you load the page. diff --git a/ideas/parser.tdsl b/ideas/parser.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..03ba9563ea8c73b7411cd1aa42dda9c7c313cd39 --- /dev/null +++ b/ideas/parser.tdsl @@ -0,0 +1,32 @@ +The parser is a series of edges/transitions, for example: +r -> reddit.com/ +where -> means that all of + r->reddit.com/ + re->reddit.com/ + red->reddit.com/ + redd->reddit.com/ + reddi->reddit.com/ + reddit->reddit.com/ + reddit.->reddit.com/ + reddit.c->reddit.com/ + reddit.co->reddit.com/ + reddit.com->reddt.com/ + are defined as autocomplete suggestions + +re -> reddit.com/ +reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/ +reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} +reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/ +reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql} + + +h->https:// (show supported domains) + +// the edges are composable such that this is possible, while reusing the above reddit code. +https://r->https://reddit.com/ +https://w->https://www. +https://www.r->https://www.reddit.com/ + + +// and i also want to have commands after you press space, like +reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% diff --git a/ideas/seed.tdsl b/ideas/seed.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..0b707179f09781c53d6526cffeb9158579698ad7 --- /dev/null +++ b/ideas/seed.tdsl @@ -0,0 +1,23 @@ +reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social? +jsonl based. Same stack as slug +fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval +Viewcounters for every site +Login with Reddit oauth? +Streaks? Streak gates? Progressive revelation? +One pair of the day +Use attributes again, for assholery. To distinguish from reddits single dimension +r/sorter_south_park_characters +Is a one shot category that only has the characters. +subs starting with sorter have special priority in sorter index. + +layer above reddit? + + +use websocket based bar i had built in afterlife... + + +claude {{ +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. +}} + +have index be sorted by viewcount. \ No newline at end of file diff --git a/legacy/bundle.js b/legacy/bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265 --- /dev/null +++ b/legacy/bundle.js @@ -0,0 +1,52 @@ +/** + * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here. + */ +(function () { + function evalJs(js) { + if (js && String(js).trim()) { + eval(js); + } + } + + // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie) + + function initSlugUi() { + // POST forms → eval response (except theme + full-navigation forms) + document.addEventListener('submit', async function (e) { + var f = e.target; + if (!f || f.tagName !== 'FORM') return; + if ((f.method || 'get').toLowerCase() !== 'post') return; + if (f.id === 'slug-theme-form') return; + if (f.getAttribute('data-navigate') === 'full') return; + e.preventDefault(); + var resp = await fetch(f.action, { + method: 'POST', + body: new URLSearchParams(new FormData(f)), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'same-origin', + }); + evalJs(await resp.text()); + }); + + // SSE: server-pushed JS + function connectSSE() { + var ssePath = window.location.pathname + window.location.search; + var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath)); + es.onmessage = function (e) { + evalJs(e.data); + }; + es.onerror = function () { + es.close(); + setTimeout(connectSSE, 3000); + }; + } + connectSSE(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSlugUi); + } else { + initSlugUi(); + } +})(); + diff --git a/legacy/clj-test.sh b/legacy/clj-test.sh new file mode 100755 index 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952 --- /dev/null +++ b/legacy/clj-test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p target +exec clojure -M:kaocha diff --git a/legacy/event_log.rs b/legacy/event_log.rs new file mode 100644 index 0000000000000000000000000000000000000000..eaae0d495e43a45d6590603892265a62cc92906e --- /dev/null +++ b/legacy/event_log.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; + +use tokio::{ + fs::{self, OpenOptions}, + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, +}; + +use crate::events::Event; + +#[derive(Debug, thiserror::Error)] +pub enum EventLogError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Debug, Clone)] +pub struct EventLog { + path: PathBuf, +} + +impl EventLog { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).await?; + } + Ok(()) + } + + pub async fn append(&self, event: &Event) -> Result<(), EventLogError> { + self.ensure_parent_dir().await?; + let mut f: tokio::fs::File = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .await?; + + let mut line = serde_json::to_string(event)?; + line.push('\n'); + f.write_all(line.as_bytes()).await?; + f.flush().await?; + Ok(()) + } + + /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`. + pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> { + if !fs::try_exists(&self.path).await? { + return Ok((vec![], vec![])); + } + + let f = fs::File::open(&self.path).await?; + let mut reader = BufReader::new(f).lines(); + + let mut events = Vec::new(); + let mut bad_lines = Vec::new(); + + let mut line_no: usize = 0; + while let Some(line) = reader.next_line().await? { + line_no += 1; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + match serde_json::from_str::(trimmed) { + Ok(ev) => events.push(ev), + Err(_) => bad_lines.push((line_no, line)), + } + } + + Ok((events, bad_lines)) + } +} + + diff --git a/legacy/forms.rs b/legacy/forms.rs new file mode 100644 index 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a --- /dev/null +++ b/legacy/forms.rs @@ -0,0 +1,145 @@ +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs +//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from +//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before** +//! deserializing into a typed struct. +//! +//! # Wire format +//! +//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty +//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string +//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not +//! bespoke encodings. +//! +//! # Power vs flat hidden fields +//! +//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`), +//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects, +//! arrays, and optional fields without inventing a new naming scheme each time. +//! +//! # Security +//! +//! Substitution runs **before** `serde` into your command type. It does not fix +//! authorization: if the client can replace the hidden `__rpc__` value, they can +//! change the command shape unless you validate (signed blob, server-side session +//! context, or treat the blob as hints only). Same threat model as any hidden field. + +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; + +/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field. +pub fn template_json_compact(v: &T) -> serde_json::Result { + serde_json::to_string(v) +} + +/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted +/// string for `key` (empty if missing). Other keys are unchanged. +pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) { + match val { + Value::Object(map) => { + if map.len() == 1 { + if let Some(Value::String(field_name)) = map.get("$form") { + let submitted = form_data + .get(field_name.as_str()) + .map(|s| s.as_str()) + .unwrap_or(""); + *val = Value::String(submitted.to_string()); + return; + } + } + for v in map.values_mut() { + substitute_form_vars(v, form_data); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + substitute_form_vars(v, form_data); + } + } + _ => {} + } +} + +/// Parse JSON, apply [`substitute_form_vars`], return the mutated value. +pub fn fill_template_from_form( + template_json: &str, + form_data: &HashMap, +) -> Result { + let mut v: Value = serde_json::from_str(template_json)?; + substitute_form_vars(&mut v, form_data); + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Demo { + room: String, + thread_tag: String, + nested: Nested, + } + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Nested { + text: String, + } + + #[test] + fn holes_become_strings() { + let json = r#"{ + "room": "public", + "thread_tag": {"$form": "tag"}, + "nested": {"text": {"$form": "body"}} + }"#; + let mut form = HashMap::new(); + form.insert("tag".into(), "foo".into()); + form.insert("body".into(), "hello\nworld".into()); + + let v = fill_template_from_form(json, &form).unwrap(); + let d: Demo = serde_json::from_value(v).unwrap(); + assert_eq!( + d, + Demo { + room: "public".into(), + thread_tag: "foo".into(), + nested: Nested { + text: "hello\nworld".into(), + }, + } + ); + } + + #[test] + fn missing_form_key_is_empty_string() { + let json = r#"{"x": {"$form": "nope"}}"#; + let mut form = HashMap::new(); + form.insert("other".into(), "y".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["x"], ""); + } + + #[test] + fn array_of_holes() { + let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#; + let mut form = HashMap::new(); + form.insert("a".into(), "1".into()); + form.insert("b".into(), "2".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["items"], serde_json::json!(["1", "2"])); + } + + #[test] + fn template_json_compact_escapes_and_single_line() { + let s = template_json_compact(&serde_json::json!({ + "x": "quote\"and\nnewline" + })) + .unwrap(); + assert!(!s.contains('\n')); + assert!(s.contains("\\\"") || s.contains("\\n")); + } +} +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ + diff --git a/legacy/mod.rs b/legacy/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..b1dfe895515a1ba94304c52ac4ea82ff6bb84c16 --- /dev/null +++ b/legacy/mod.rs @@ -0,0 +1,929 @@ +use axum::{ + body::Body, + extract::Path, + http::{header, HeaderValue, StatusCode, Uri}, + response::{IntoResponse, Response}, + Form, +}; +use axum_extra::extract::cookie::CookieJar; +use maud::{html, Markup, DOCTYPE}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; + +mod auth; +mod breadcrumb_path; +mod editor; +mod forum; +mod garden; +pub mod routing; +mod search; +pub mod ui_action; +use breadcrumb_path::{ExternalOntologyPath, OntologyPath}; + +pub use auth::{ + auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, + choose_username_page, +}; +pub use editor::{editor_check, editor_page}; +pub use forum::{ + home, room_page, room_thread_post_view, room_thread_view, thread_feed_html, + thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav, +}; + +pub use forum::user_profile_page; +pub(crate) use forum::{ + fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup, + thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post, + user_can_post_room, user_can_view_room, +}; +pub(crate) use garden::{ + encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js, + GARDEN_PIN_COOKIE, +}; +pub use garden::{ + external_garden_index, external_ontology_path, garden_index, ontology_path, + room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path, + room_vote_compare_page, vote_compare_page, +}; +pub use routing::RouteContext; +pub use search::{search_page, search_results_fragment}; +pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD}; + +/// Public profile URL path for a stored username (no `@`). +pub(crate) fn profile_href(username: &str) -> String { + format!("/u/{username}") +} + +/// Cookie name for UI theme (must match document.cookie migration in [`layout`]). +pub const SLUG_THEME_COOKIE: &str = "slug-theme"; + +/// Normalize a requested theme id to a known stylesheet key. +pub fn normalize_theme(raw: &str) -> &'static str { + match raw { + "retro" => "retro", + "retro_craft" => "retro_craft", + _ => "default", + } +} + +/// Resolved theme for rendering and cookie re-issue. +pub fn theme_from_jar(jar: &CookieJar) -> &'static str { + jar.get(SLUG_THEME_COOKIE) + .map(|c| normalize_theme(c.value())) + .unwrap_or("default") +} + +/// `Path` + optional `?query` for round-tripping after `POST /theme`. +pub fn theme_next_from_uri(uri: &Uri) -> String { + uri.path_and_query() + .map(|pq| pq.as_str().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "/".to_string()) +} + +/// `Set-Cookie` for a validated theme (ASCII cookie value). +pub fn theme_cookie_header_value(theme: &str) -> HeaderValue { + let t = normalize_theme(theme); + let s = format!("{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000"); + HeaderValue::from_str(&s).expect("theme cookie must be ASCII") +} + +/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme. +pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option { + let c = jar.get(SLUG_THEME_COOKIE)?; + Some(theme_cookie_header_value(c.value())) +} + +fn sanitize_theme_next(next: Option<&str>) -> String { + let s = next.unwrap_or("/").trim(); + if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 { + s.to_string() + } else { + "/".to_string() + } +} + +#[derive(Debug, Deserialize)] +pub struct ThemeForm { + theme: String, + next: Option, +} + +/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch). +pub async fn post_theme(Form(form): Form) -> impl IntoResponse { + let theme = normalize_theme(&form.theme); + let next = sanitize_theme_next(form.next.as_deref()); + let loc = + HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static("/")); + Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, loc) + .header(header::SET_COOKIE, theme_cookie_header_value(theme)) + .body(Body::empty()) + .expect("theme redirect response") +} + +// Embed CSS and shared UI script at compile time +const THEME_DEFAULT_CSS: &str = include_str!("../../static/theme_default.css"); +const THEME_RETRO_CSS: &str = include_str!("../../static/theme_retro.css"); +const THEME_RETRO_CRAFT_CSS: &str = include_str!("../../static/theme_retro_craft.css"); +const SLUG_UI_JS: &str = include_str!("../../static/slug_ui.js"); + +pub async fn serve_static(Path(filename): Path) -> impl IntoResponse { + if filename == "slug_ui.js" { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(SLUG_UI_JS.to_string()) + .unwrap() + .into_response(); + } + + let theme = filename + .strip_prefix("theme_") + .and_then(|s| s.strip_suffix(".css")); + + let css = match theme { + Some("default") => THEME_DEFAULT_CSS, + Some("retro") => THEME_RETRO_CSS, + Some("retro_craft") => THEME_RETRO_CRAFT_CSS, + _ => return (StatusCode::NOT_FOUND, "static file not found").into_response(), + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/css; charset=utf-8") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(css.to_string()) + .unwrap() + .into_response() +} + +pub(crate) fn js_string_literal(s: &str) -> String { + serde_json::to_string(s).expect("javascript string escaping") +} + +/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures. +pub(crate) fn ui_js_warn(msg: &str) -> Response { + let js = format!("console.warn({});", js_string_literal(msg)); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") + .body(Body::from(js)) + .unwrap() +} + +pub(crate) struct JsBuilder { + snippets: Vec, +} + +pub(crate) struct JsQueryBuilder { + builder: JsBuilder, + expr: String, +} + +impl JsBuilder { + pub(crate) fn new() -> Self { + Self { + snippets: Vec::new(), + } + } + + pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self { + self.morph_expr( + &format!("document.querySelector({})", js_string_literal(selector)), + markup, + None, + ) + } + + /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`. + pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self { + self.qs(selector).morph_inner(markup) + } + + pub(crate) fn morph_expr( + mut self, + expr: &str, + markup: Markup, + morph_style: Option<&str>, + ) -> Self { + let html = js_string_literal(&markup.into_string()); + let opts = morph_style + .map(|style| format!(", {{morphStyle: {}}}", js_string_literal(style))) + .unwrap_or_default(); + self.snippets.push(format!( + "var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}", + )); + self + } + + pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder { + JsQueryBuilder { + builder: self, + expr: format!("document.querySelector({})", js_string_literal(selector)), + } + } + + pub(crate) fn id(self, id: &str) -> JsQueryBuilder { + self.qs(&format!("#{id}")) + } + + pub(crate) fn if_current_path_matches( + mut self, + path: &str, + f: impl FnOnce(JsBuilder) -> JsBuilder, + ) -> Self { + let inner = f(JsBuilder::new()).build(); + self.snippets.push(format!( + "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}", + path = js_string_literal(path), + )); + self + } + + pub(crate) fn if_current_path_not_matches( + mut self, + path: &str, + f: impl FnOnce(JsBuilder) -> JsBuilder, + ) -> Self { + let inner = f(JsBuilder::new()).build(); + self.snippets.push(format!( + "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}", + path = js_string_literal(path), + )); + self + } + + pub(crate) fn redirect(mut self, to: &str) -> Self { + self.snippets + .push(format!("window.location = {};", js_string_literal(to))); + self + } + + pub(crate) fn clipboard_write_text_and_label_btn( + mut self, + text: &str, + btn_id: &str, + copied_label: &str, + ) -> Self { + self.snippets.push(format!( + "navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});", + text = js_string_literal(text), + btn_id = js_string_literal(btn_id), + label = js_string_literal(copied_label), + )); + self + } + + /// Focus first matching element (e.g. after morphing open a compose form). + pub(crate) fn focus_selector(mut self, selector: &str) -> Self { + self.snippets.push(format!( + "var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}", + js_string_literal(selector), + )); + self + } + + pub(crate) fn build(self) -> String { + self.snippets.join(" ") + } + + pub(crate) fn into_response(self) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") + .body(Body::from(self.build())) + .unwrap() + } +} + +impl JsQueryBuilder { + pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder { + let html = js_string_literal(&markup.into_string()); + self.builder.snippets.push(format!( + "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}", + expr = self.expr, + )); + self.builder + } + + pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder { + let html = js_string_literal(&markup.into_string()); + self.builder.snippets.push(format!( + "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}", + expr = self.expr, + )); + self.builder + } + + pub(crate) fn reset(mut self) -> JsBuilder { + self.builder.snippets.push(format!( + "var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}", + expr = self.expr, + )); + self.builder + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn layout( + title: &str, + view: &str, + body: Markup, + views: Option, + theme: &str, + theme_next: &str, + garden_room_wire: Option<&str>, + garden_path_prefix: Option<&str>, +) -> Markup { + layout_embed_controls( + title, + view, + body, + views, + theme, + theme_next, + garden_room_wire, + garden_path_prefix, + true, + ) +} + +/// Minimal document shell: no bottom controls, no garden HUD data attributes (`data-garden-room` / +/// `data-garden-prefix` empty). For routes that own the full viewport (e.g. vote compare). +pub(super) fn layout_full_bleed_chromeless( + title: &str, + view: &str, + body: Markup, + views: Option, + theme: &str, + theme_next: &str, +) -> Markup { + layout_embed_controls( + title, view, body, views, theme, theme_next, None, None, false, + ) +} + +#[allow(clippy::too_many_arguments)] +fn layout_embed_controls( + title: &str, + view: &str, + body: Markup, + views: Option, + theme: &str, + theme_next: &str, + garden_room_wire: Option<&str>, + garden_path_prefix: Option<&str>, + show_controls: bool, +) -> Markup { + let theme = normalize_theme(theme); + let css_href = format!("/static/theme_{theme}.css"); + html! { + (DOCTYPE) + html { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) } + link rel="stylesheet" href=(css_href) id="theme-stylesheet"; + script src="https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js" {} + } + body class=(view) + data-garden-room=(garden_room_wire.unwrap_or("")) + data-garden-prefix=(garden_path_prefix.unwrap_or("")) { + @if let Some(n) = views { + span class="view-meta muted" { (n) " views" } + } + div id="errors" {} + (body) + @if show_controls { + div id="controls" { + a href="https://github.com/sortersocial/slug" id="src-link" { "src" } + div id="spread-control" { + span { "spread" } + input type="range" id="spread-slider" min="0" max="1" step="0.05" value="1"; + } + @if let (Some(gr), Some(ref gpx)) = (garden_room_wire, garden_path_prefix) { + @if !gr.is_empty() { + div id="slug-pin-hud" class="slug-pin-hud" data-garden-prefix=(gpx) {} + } + } + a id="search-btn" href="/search" { "search" } + form id="slug-theme-form" method="post" action="/theme" { + input type="hidden" name="next" value=(theme_next); + select id="theme-select" name="theme" onchange="this.form.submit()" aria-label="Theme" { + @for (val, label) in [("default", "default"), ("retro", "retro"), ("retro_craft", "craft")] { + @if theme == val { + option value=(val) selected { (label) } + } @else { + option value=(val) { (label) } + } + } + } + } + } + } + script src="/static/slug_ui.js" {} + } + } + } +} + +pub(super) fn now_ms() -> i64 { + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + t.as_millis() as i64 +} + +pub(super) fn ratio_pct(left: i32, right: i32) -> f64 { + let l = (left.max(0)) as f64; + let r = (right.max(0)) as f64; + let denom = l + r; + if denom <= 0.0 { + return 50.0; + } + (l / denom) * 100.0 +} + +/// Render a single breadcrumb segment with `/` separator. +pub(super) fn bc_segment(label: &str, href: &str, is_current: bool) -> Markup { + html! { + span class="bc-sep" { " / " } + @if is_current { + a href=(href) class="bc-current" { (label) } + } @else { + a href=(href) { (label) } + } + } +} + +/// Breadcrumb for external ontology `/-/https://…` +fn bc_path_external(path: &ExternalOntologyPath) -> Markup { + html! { + a href="/" { "slug.social" } + (bc_segment("-", "/-", path.is_root())) + @for (i, id) in path.breadcrumb_chain().iter().enumerate() { + @let disp = id.display_path(); + @let tail = disp.strip_prefix("-/").unwrap_or(disp.as_str()); + @let href = format!("/-/{}", tail); + @let is_last = i + 1 == path.breadcrumb_chain().len(); + (bc_segment(id.last_segment(), &href, is_last)) + } + } +} + +/// Breadcrumb for any ontology path segment, e.g. "parables" or "parables/counting-the-cost". +fn bc_path(path: &OntologyPath) -> Markup { + html! { + a href=(path.slug_root_href()) { "slug.social" } + (bc_segment("~", "/~", path.is_root())) + @for (i, seg) in path.segments().iter().enumerate() { + @let href = format!("/~/{}", path.segments()[..=i].join("/")); + @let is_last = i == path.segments().len() - 1; + (bc_segment(seg, &href, is_last)) + } + } +} + +/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post. +/// Root link toggles to `/~` only at thread-root (`/`). +pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup { + let root_href = if thread_tag.is_some() { "/" } else { "/~" }; + let root_is_current = thread_tag.is_none(); + html! { + @if root_is_current { + a href=(root_href) class="bc-current" { "slug.social" } + } @else { + a href=(root_href) { "slug.social" } + } + @if let Some(tag) = thread_tag { + @if let Some(idx) = focused_post { + (bc_segment(&format!("#{tag}"), &format!("/t/{tag}"), false)) + (bc_segment(&format!("post #{idx}"), &format!("/t/{tag}/{idx}"), true)) + } @else { + (bc_segment(&format!("#{tag}"), &format!("/t/{tag}"), true)) + } + } + } +} + +/// Short display label for a stored agent id (`uuid:rig:model`, no `@`). +pub(super) fn actor_label(agent_naked: &str) -> String { + let a = agent_naked.trim(); + let parts: Vec<&str> = a.split(':').collect(); + if parts.len() >= 3 { + let rig = parts[1].trim(); + let model = parts[2].trim(); + // X actors: show @handle instead of uuid hash. + if rig == "x.com" { + return format!("@{model}"); + } + let uuid = parts[0].trim(); + let uuid8 = uuid.chars().take(8).collect::(); + if !uuid8.is_empty() && !rig.is_empty() && !model.is_empty() { + return format!("{uuid8}:{rig}:{model}"); + } + } + a.to_string() +} + +/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present. +pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String { + match delegate { + Some(d) => format!("@@{}", actor_label(d)), + None => format!("@{principal}"), + } +} + +/// Escape HTML special chars for safe injection. +fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } + out +} + +/// Max characters from an item body placed in a `title` tooltip (native hover). +const ITEM_LINK_TITLE_MAX_CHARS: usize = 500; + +fn collapse_whitespace_for_title(s: &str) -> String { + let mut out = String::with_capacity(s.len().min(ITEM_LINK_TITLE_MAX_CHARS + 8)); + let mut last_was_space = true; + for c in s.chars() { + if c.is_whitespace() { + if !last_was_space { + out.push(' '); + last_was_space = true; + } + } else { + out.push(c); + last_was_space = false; + } + } + out.trim().to_string() +} + +fn item_body_title_snippet(body: &str) -> Option { + let s = collapse_whitespace_for_title(body); + if s.is_empty() { + return None; + } + let truncated: String = s.chars().take(ITEM_LINK_TITLE_MAX_CHARS).collect(); + let ellipsis = if s.chars().count() > ITEM_LINK_TITLE_MAX_CHARS { + "…" + } else { + "" + }; + Some(format!("{truncated}{ellipsis}")) +} + +fn garden_href_for_item_ref( + raw_ref: &str, + garden_prefix: &str, +) -> Option<(crate::path_types::ItemId, String)> { + let key = slug_types::canonicalize_item(raw_ref); + let id = crate::path_types::ItemId::parse(&key)?; + let href = if let Some(tail) = id.tilde_tail() { + if tail.is_empty() { + garden_prefix.trim_end_matches('/').to_string() + } else { + format!("{}/{}", garden_prefix.trim_end_matches('/'), tail) + } + } else if id.as_str().starts_with("https://") || id.as_str().starts_with("http://") { + let display = id.display_path(); + let rest = display.strip_prefix("-/").unwrap_or(display.as_str()); + let ext_prefix = format!("{}-", garden_prefix.trim_end_matches('~')); + format!("{ext_prefix}/{rest}") + } else { + return None; + }; + Some((id, href)) +} + +fn push_item_ref_anchor( + out: &mut String, + raw_ref: &str, + garden_prefix: &str, + item_bodies: Option<&HashMap>, +) -> bool { + let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else { + return false; + }; + out.push_str(r#"'); + out.push_str(&escape_html(raw_ref)); + out.push_str(""); + true +} + +/// Replace item refs in raw prose with clickable garden links. +/// +/// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated +/// body preview for native browser tooltips (forum posts, item pages). +pub(super) fn linkify_slugs_with_prefix( + raw: &str, + garden_prefix: &str, + item_bodies: Option<&HashMap>, +) -> String { + let mut out = String::with_capacity(raw.len() + 64); + for token in crate::dsl::tokenize_prose_item_refs(raw) { + match token { + crate::dsl::ProseToken::Text(text) => out.push_str(&escape_html(&text)), + crate::dsl::ProseToken::ItemRef(raw_ref) => { + if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) { + out.push_str(&escape_html(&raw_ref)); + } + } + } + } + out +} + +#[derive(Clone)] +struct EmbedFrame { + src: String, + title: String, + provider_class: &'static str, +} + +fn clean_media_id(s: &str) -> String { + s.chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect() +} + +fn query_param(url: &str, name: &str) -> Option { + let q = url.split_once('?')?.1; + for pair in q.split('&') { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + if k == name { + return Some(v.to_string()); + } + } + None +} + +fn spotify_embed_src(url: &str) -> Option { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let (host, tail) = rest.split_once('/').unwrap_or((rest, "")); + let host = host.to_lowercase(); + if !(host == "open.spotify.com" || host == "www.open.spotify.com") { + return None; + } + let path = tail + .split('#') + .next() + .unwrap_or(tail) + .split('?') + .next() + .unwrap_or(tail); + let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if segs.first().is_some_and(|s| s.starts_with("intl-")) { + segs.remove(0); + } + if segs.len() < 2 { + return None; + } + let kind = segs[0]; + let id = clean_media_id(segs[1]); + if id.is_empty() { + return None; + } + let allowed = matches!(kind, "track" | "album" | "playlist" | "episode" | "show"); + if !allowed { + return None; + } + Some(EmbedFrame { + src: format!("https://open.spotify.com/embed/{kind}/{id}"), + title: format!("Spotify {kind}"), + provider_class: "embed-spotify", + }) +} + +fn youtube_embed_src(url: &str) -> Option { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let (host, tail) = rest.split_once('/').unwrap_or((rest, "")); + let host = host.to_lowercase(); + + let video_id = if host == "youtu.be" || host == "www.youtu.be" { + clean_media_id( + tail.split(['?', '#']) + .next() + .unwrap_or(tail) + .trim_matches('/'), + ) + } else if matches!( + host.as_str(), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" + ) { + let path = format!("/{}", tail.split('#').next().unwrap_or(tail)); + if path.starts_with("/watch") { + clean_media_id(&query_param(url, "v")?) + } else if let Some(id) = path.strip_prefix("/shorts/") { + clean_media_id(id.split(['?', '/']).next().unwrap_or(id)) + } else if let Some(id) = path.strip_prefix("/embed/") { + clean_media_id(id.split(['?', '/']).next().unwrap_or(id)) + } else { + String::new() + } + } else { + String::new() + }; + + if video_id.is_empty() { + return None; + } + Some(EmbedFrame { + src: format!("https://www.youtube.com/embed/{video_id}"), + title: "YouTube video".to_string(), + provider_class: "embed-youtube", + }) +} + +fn extract_embed_frames(raw: &str) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for token in raw.split_whitespace() { + let token = token + .trim_matches(|c: char| c == '(' || c == '[' || c == '{' || c == '"' || c == '\'') + .trim_end_matches(|c: char| ".,;:!?)]}\"'".contains(c)); + if !(token.starts_with("https://") || token.starts_with("http://")) { + continue; + } + let embed = spotify_embed_src(token).or_else(|| youtube_embed_src(token)); + if let Some(frame) = embed { + if seen.insert(frame.src.clone()) { + out.push(frame); + } + } + } + out +} + +pub(super) fn render_linkified_with_embeds_in_scope( + raw: &str, + garden_prefix: &str, + item_bodies: Option<&HashMap>, +) -> Markup { + let embeds = extract_embed_frames(raw); + html! { + pre { (maud::PreEscaped(linkify_slugs_with_prefix(raw, garden_prefix, item_bodies))) } + @if !embeds.is_empty() { + div class="rich-embeds" { + @for e in embeds { + div class=(format!("rich-embed {}", e.provider_class)) { + iframe + src=(e.src) + title=(e.title) + loading="lazy" + allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share" + referrerpolicy="strict-origin-when-cross-origin" + allowfullscreen {} + } + } + } + } + } +} + +/// Item page / thread body: resolver-specific rich HTML, else linkified `
` + media embeds.
+pub(super) fn render_item_body_in_scope(
+    raw: &str,
+    garden_prefix: &str,
+    item_bodies: Option<&HashMap>,
+) -> Markup {
+    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {
+        return html! {
+            div class="item-body-rich" { (m) }
+        };
+    }
+    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)
+}
+
+/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
+fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
+    assert!(
+        !s.contains('\\') && !s.contains('\'') && !s.contains('\n') && !s.contains('\r'),
+        "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})"
+    );
+}
+
+/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.
+pub(super) fn cli_panel>(cmds: &[I]) -> Markup {
+    if cmds.is_empty() {
+        return html! {};
+    }
+    for cmd in cmds {
+        assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());
+    }
+    html! {
+        div class="cli-panel" {
+            span class="cli-panel-label muted" { "cli" }
+            div class="cli-panel-cmds" {
+                @for cmd in cmds {
+                    @let s = cmd.as_ref();
+                    button type="button" class="cli-panel-row" title="Copy command" onclick=(format!(
+                        r#"navigator.clipboard.writeText('{}');"#,
+                        s
+                    )) {
+                        code class="cli-panel-cmd" { (s) }
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// Age bucket for recency coloring of thread entries.
+pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
+    let age_ms = now_ms.saturating_sub(ts_ms);
+    let age_secs = age_ms / 1000;
+    if age_secs < 3600 {
+        "age-fresh" // < 1 hour
+    } else if age_secs < 86400 {
+        "age-recent" // < 1 day
+    } else if age_secs < 86400 * 7 {
+        "age-week" // < 1 week
+    } else {
+        "age-old" // >= 1 week
+    }
+}
+
+#[cfg(test)]
+mod linkify_title_tests {
+    use super::*;
+    use crate::path_types::ItemId;
+    use std::collections::HashMap;
+
+    #[test]
+    fn tilde_link_gets_title_from_item_bodies() {
+        let mut bodies = HashMap::new();
+        let key = ItemId::parse(&slug_types::canonicalize_item("~/foo/bar")).unwrap();
+        bodies.insert(key, "Hello  world\nline".to_string());
+        let html = linkify_slugs_with_prefix("see ~/foo/bar ok", "/r/x/~", Some(&bodies));
+        assert!(html.contains("title=\"Hello world line\""));
+        assert!(html.contains("href=\"/r/x/~/foo/bar\""));
+    }
+
+    #[test]
+    fn raw_url_links_to_public_external_garden_page() {
+        let html = linkify_slugs_with_prefix("see https://example.com/z.", "/~", None);
+        assert!(html.contains(
+            r#"https://example.com/z."#
+        ));
+    }
+
+    #[test]
+    fn dash_ref_links_to_room_external_garden_page_with_title() {
+        let mut bodies = HashMap::new();
+        let key = ItemId::parse(&slug_types::canonicalize_item("-/example.com/z")).unwrap();
+        bodies.insert(key, "External body\npreview".to_string());
+        let html =
+            linkify_slugs_with_prefix("see -/example.com/z", "/r/9ab12cdroom/~", Some(&bodies));
+        assert!(html.contains(r#"href="/r/9ab12cdroom/-/https://example.com/z""#));
+        assert!(html.contains(r#"title="External body preview""#));
+    }
+
+    #[test]
+    fn code_fence_urls_are_not_linkified() {
+        let html = linkify_slugs_with_prefix(
+            "```json\n{\"url\":\"https://example.com/z\"}\n```\nthen https://example.com/a",
+            "/~",
+            None,
+        );
+        assert!(!html.contains(r#"href="/-/https://example.com/z""#));
+        assert!(html.contains(r#"href="/-/https://example.com/a""#));
+    }
+
+    #[test]
+    fn no_title_when_body_missing_or_empty() {
+        let html = linkify_slugs_with_prefix("x ~/a/b y", "/~", Some(&HashMap::new()));
+        assert!(!html.contains(" title="));
+    }
+}
diff --git a/legacy/parser.rs b/legacy/parser.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349
--- /dev/null
+++ b/legacy/parser.rs
@@ -0,0 +1,1808 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+use std::cell::RefCell;
+use crate::ui::action::UIAction;
+use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};
+
+// --- Core Abstractions ---
+
+/// Unique identifier for nodes in the graph
+type NodeId = &'static str;
+
+/// Pattern matching for edges
+#[derive(Debug, Clone)]
+pub enum EdgePattern {
+    /// Matches exact literal string
+    Literal(&'static str),
+    
+    /// Matches any prefix of a string and suggests the full string
+    /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
+    PrefixOf(&'static str),
+    
+    /// Captures a variable segment (e.g., subreddit name, username)
+    Variable(&'static str),
+    
+    /// Matches any string (wildcard)
+    Any,
+}
+
+impl EdgePattern {
+    /// Try to match this pattern against input, return (consumed_chars, captured_value)
+    fn matches(&self, input: &str) -> Option<(usize, Option)> {
+        match self {
+            EdgePattern::Literal(lit) => {
+                if input.starts_with(lit) {
+                    Some((lit.len(), None))
+                } else {
+                    None
+                }
+            }
+            EdgePattern::PrefixOf(target) => {
+                // Check if input is a prefix of target
+                if target.starts_with(input) && !input.is_empty() {
+                    // It's a valid prefix
+                    Some((input.len(), None))
+                } else if input.starts_with(target) {
+                    // Full match
+                    Some((target.len(), None))
+                } else {
+                    None
+                }
+            }
+            EdgePattern::Variable(var_name) => {
+                // Consume until next '/' or end of string
+                let end = input.find('/').unwrap_or(input.len());
+                if end > 0 {
+                    let captured = input[..end].to_string();
+                    // Validate based on variable type
+                    if is_valid_variable(var_name, &captured) {
+                        Some((end, Some(captured)))
+                    } else {
+                        None
+                    }
+                } else {
+                    None
+                }
+            }
+            EdgePattern::Any => {
+                // Match everything until next '/' or end
+                let end = input.find('/').unwrap_or(input.len());
+                if end > 0 {
+                    Some((end, Some(input[..end].to_string())))
+                } else {
+                    None
+                }
+            }
+        }
+    }
+    
+    /// Get the completion suggestion for this pattern
+    fn completion(&self, partial: &str) -> Option {
+        match self {
+            EdgePattern::PrefixOf(target) => {
+                if target.starts_with(partial) && partial != *target {
+                    Some(target.to_string())
+                } else {
+                    None
+                }
+            }
+            _ => None,
+        }
+    }
+}
+
+/// Edge in the graph
+pub struct Edge {
+    pattern: EdgePattern,
+    target: NodeId,
+    /// Optional description for autocomplete
+    description: Option<&'static str>,
+}
+
+/// Handler function for generating UI actions
+type Handler = Box) -> UIAction>;
+
+/// Node in the graph
+pub struct Node {
+    #[allow(dead_code)]
+    id: NodeId,
+    edges: Vec,
+    handler: Option,
+}
+
+/// The composable parser graph
+pub struct Graph {
+    nodes: HashMap>>,
+    root: NodeId,
+}
+
+// --- Graph Builder (Fluent API) ---
+
+pub struct GraphBuilder {
+    nodes: HashMap>>,
+    current_node: Option,
+    root: NodeId,
+}
+
+impl GraphBuilder {
+    pub fn new() -> Self {
+        let mut nodes = HashMap::new();
+        let root_node = Rc::new(RefCell::new(Node {
+            id: "root",
+            edges: Vec::new(),
+            handler: None,
+        }));
+        nodes.insert("root", root_node);
+        
+        GraphBuilder {
+            nodes,
+            current_node: Some("root"),
+            root: "root",
+        }
+    }
+    
+    /// Select a node to add edges to
+    pub fn at(mut self, node_id: NodeId) -> Self {
+        // Create node if it doesn't exist
+        if !self.nodes.contains_key(node_id) {
+            let node = Rc::new(RefCell::new(Node {
+                id: node_id,
+                edges: Vec::new(),
+                handler: None,
+            }));
+            self.nodes.insert(node_id, node);
+        }
+        self.current_node = Some(node_id);
+        self
+    }
+    
+    /// Add an edge from the current node
+    pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
+        self.edge_with_desc(pattern, target, None)
+    }
+    
+    /// Add an edge with description
+    pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {
+        let current = self.current_node.expect("No current node selected");
+        
+        // Create target node if it doesn't exist
+        if !self.nodes.contains_key(target) {
+            let node = Rc::new(RefCell::new(Node {
+                id: target,
+                edges: Vec::new(),
+                handler: None,
+            }));
+            self.nodes.insert(target, node);
+        }
+        
+        // Add edge to current node
+        if let Some(node) = self.nodes.get(current) {
+            node.borrow_mut().edges.push(Edge {
+                pattern,
+                target,
+                description: desc,
+            });
+        }
+        
+        self
+    }
+    
+    /// Set handler for current node
+    pub fn handler(self, handler: F) -> Self 
+    where 
+        F: Fn(&str, &str, &HashMap) -> UIAction + 'static
+    {
+        let current = self.current_node.expect("No current node selected");
+        if let Some(node) = self.nodes.get(current) {
+            node.borrow_mut().handler = Some(Box::new(handler));
+        }
+        self
+    }
+    
+    /// Build the final graph
+    pub fn build(self) -> Graph {
+        Graph {
+            nodes: self.nodes,
+            root: self.root,
+        }
+    }
+}
+
+// --- Parser Implementation ---
+
+impl Graph {
+    pub fn parse(&self, input: &str) -> UIAction {
+        let normalized = input.trim().to_lowercase();
+        let mut state = ParserState {
+            input: &normalized,
+            cursor: 0,
+            current_node_id: self.root,
+            context: HashMap::new(),
+            original_query: input.to_string(),
+            current_prefix: String::new(),
+        };
+        
+        self.parse_recursive(&mut state)
+    }
+    
+    fn parse_recursive(&self, state: &mut ParserState) -> UIAction {
+        let node = self.nodes.get(state.current_node_id)
+            .expect("Node not found in graph");
+        let node_ref = node.borrow();
+        
+        // If we've consumed all input, check for handler or suggestions
+        if state.cursor >= state.input.len() {
+            if let Some(handler) = &node_ref.handler {
+                return handler(&state.original_query, &state.current_prefix, &state.context);
+            }
+            
+            // No handler, try to suggest based on available edges
+            return self.suggest_from_edges(&node_ref, state);
+        }
+        
+        let remaining = &state.input[state.cursor..];
+        
+        // Try to match each edge
+        for edge in &node_ref.edges {
+            if let Some((consumed, captured)) = edge.pattern.matches(remaining) {
+                // Save state for potential backtracking
+                let saved_cursor = state.cursor;
+                let saved_node = state.current_node_id;
+                let saved_prefix = state.current_prefix.clone();
+                
+                // Update state
+                state.cursor += consumed;
+                state.current_node_id = edge.target;
+                state.current_prefix.push_str(&remaining[..consumed]);
+                
+                // Store captured variable if any
+                if let Some(value) = captured {
+                    if let EdgePattern::Variable(var_name) = &edge.pattern {
+                        state.context.insert(var_name.to_string(), value);
+                    }
+                }
+                
+                // Check if this is a partial match that needs completion
+                if state.cursor == state.input.len() {
+                    if let Some(completion_suffix) = edge.pattern.completion(remaining) {
+                        // Use the current_prefix plus the completion suffix
+                        let full_completion = format!("{}{}", 
+                            state.current_prefix, 
+                            completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)
+                        );
+                        return UIAction::suggest(
+                            state.original_query.clone(),
+                            Some(Suggestion {
+                                text: full_completion.clone(),
+                                completion: full_completion,
+                                description: edge.description.map(|d| d.to_string()),
+                                score: 1.0,
+                            })
+                        );
+                    }
+                }
+                
+                // Continue parsing from the target node
+                let result = self.parse_recursive(state);
+                
+                // If we got a valid response, return it
+                if !matches!(result, UIAction::ShowError(_)) {
+                    return result;
+                }
+                
+                // Otherwise, restore state and try next edge
+                state.cursor = saved_cursor;
+                state.current_node_id = saved_node;
+                state.current_prefix = saved_prefix;
+            }
+        }
+        
+        // No edges matched - try to provide suggestions
+        self.suggest_from_edges(&node_ref, state)
+    }
+    
+    fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {
+        let remaining = &state.input[state.cursor..];
+        
+        // Find edges that could match with more input
+        for edge in &node.edges {
+            match &edge.pattern {
+                EdgePattern::PrefixOf(target) => {
+                    if target.starts_with(remaining) && !remaining.is_empty() {
+                        // Use current_prefix instead of rebuilding from input
+                        let full_completion = format!("{}{}", state.current_prefix, target);
+                        return UIAction::suggest(
+                            state.original_query.clone(),
+                            Some(Suggestion {
+                                text: full_completion.clone(),
+                                completion: full_completion,
+                                description: edge.description.map(|d| d.to_string()),
+                                score: 1.0,
+                            })
+                        );
+                    }
+                }
+                EdgePattern::Literal(lit) => {
+                    if lit.starts_with(remaining) && !remaining.is_empty() {
+                        let full_completion = format!("{}{}", state.current_prefix, lit);
+                        return UIAction::suggest(
+                            state.original_query.clone(),
+                            Some(Suggestion {
+                                text: full_completion.clone(),
+                                completion: full_completion,
+                                description: edge.description.map(|d| d.to_string()),
+                                score: 1.0,
+                            })
+                        );
+                    }
+                }
+                _ => {}
+            }
+        }
+        
+        UIAction::error(
+            "InvalidPath".to_string(),
+            format!("'{}' doesn't match any known pattern", state.original_query)
+        )
+    }
+}
+
+struct ParserState<'a> {
+    input: &'a str,
+    cursor: usize,
+    current_node_id: NodeId,
+    context: HashMap,
+    original_query: String,
+    current_prefix: String,
+}
+
+// --- Helper Functions ---
+
+fn is_valid_variable(var_name: &str, value: &str) -> bool {
+    match var_name {
+        "subreddit" => {
+            !value.is_empty() && 
+            value.len() <= 21 && 
+            value.chars().all(|c| c.is_alphanumeric() || c == '_')
+        }
+        "username" => {
+            !value.is_empty() && 
+            value.len() <= 20 && 
+            value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')
+        }
+        "post_id" => {
+            !value.is_empty() && 
+            value.len() <= 10 && 
+            value.chars().all(|c| c.is_alphanumeric())
+        }
+        _ => true, // Allow any value for unknown variables
+    }
+}
+
+// --- Define the Reddit Graph ---
+
+pub fn build_reddit_graph() -> Graph {
+    GraphBuilder::new()
+        // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===
+        .at("root")
+            // Direct aliases to subreddit and user selection
+            .edge_with_desc(
+                EdgePattern::PrefixOf("r/"),
+                "subreddit_selection",
+                Some("Browse subreddits (e.g., r/programming)")
+            )
+            .edge_with_desc(
+                EdgePattern::PrefixOf("u/"),
+                "user_selection",
+                Some("Browse users (e.g., u/spez)")
+            )
+            
+            // Reddit shortcuts - one pattern handles ALL prefixes!
+            .edge_with_desc(
+                EdgePattern::PrefixOf("reddit.com"), 
+                "reddit_domain",
+                Some("Go to Reddit")
+            )
+            
+            // Protocol patterns - "h" can suggest "https://"
+            .edge_with_desc(
+                EdgePattern::PrefixOf("https://"),
+                "https_protocol",
+                Some("HTTPS protocol")
+            )
+            .edge_with_desc(
+                EdgePattern::PrefixOf("http://"),
+                "http_protocol", 
+                Some("HTTP protocol")
+            )
+            .edge_with_desc(
+                EdgePattern::PrefixOf("www."),
+                "www_prefix",
+                Some("World Wide Web")
+            )
+        
+
+        
+        // === HTTPS PROTOCOL: Can go to any domain ===
+        .at("https_protocol")
+            .edge_with_desc(
+                EdgePattern::PrefixOf("reddit.com"),
+                "reddit_domain",
+                Some("Reddit (HTTPS)")
+            )
+            .edge_with_desc(
+                EdgePattern::PrefixOf("www."),
+                "https_www",
+                Some("WWW prefix")
+            )
+        
+        // === HTTP PROTOCOL: Similar to HTTPS ===
+        .at("http_protocol")
+            .edge_with_desc(
+                EdgePattern::PrefixOf("reddit.com"),
+                "reddit_domain",
+                Some("Reddit (HTTP)")
+            )
+            .edge_with_desc(
+                EdgePattern::PrefixOf("www."),
+                "http_www",
+                Some("WWW prefix")
+            )
+        
+        // === HTTPS + WWW ===
+        .at("https_www")
+            .edge_with_desc(
+                EdgePattern::PrefixOf("reddit.com"),
+                "reddit_domain",
+                Some("Reddit")
+            )
+        
+        // === HTTP + WWW ===
+        .at("http_www")
+            .edge_with_desc(
+                EdgePattern::PrefixOf("reddit.com"),
+                "reddit_domain",
+                Some("Reddit")
+            )
+        
+        // === WWW PREFIX (without protocol) ===
+        .at("www_prefix")
+            .edge_with_desc(
+                EdgePattern::PrefixOf("reddit.com"),
+                "reddit_domain",
+                Some("Reddit")
+            )
+        
+        // === REDDIT DOMAIN: Expect "/" ===
+        .at("reddit_domain")
+            .edge(EdgePattern::Literal("/"), "reddit_root")
+            .handler(|query, prefix, _ctx| {
+                // If someone just types "reddit.com" (or with protocol) without slash
+                // Suggest adding the slash using the current prefix
+                let completion = format!("{}/", prefix);
+                
+                UIAction::suggest(
+                    query.to_string(),
+                    Some(Suggestion {
+                        text: completion.clone(),
+                        completion,
+                        description: Some("Continue to Reddit homepage".to_string()),
+                        score: 1.0,
+                    })
+                )
+            })
+        
+        // === REDDIT ROOT: The main Reddit navigation ===
+        .at("reddit_root")
+            .edge(EdgePattern::Literal("r/"), "subreddit_selection")
+            .edge(EdgePattern::Literal("u/"), "user_selection")
+            .handler(|query, prefix, _ctx| {
+                UIAction::multiple(vec![
+                    UIAction::scrolling_suggestions(
+                        query.to_string(),
+                        vec![
+                            ScrollingSuggestion {
+                                completion: format!("{}r/", prefix),
+                            },
+                            ScrollingSuggestion {
+                                completion: format!("{}u/", prefix),
+                            },
+                        ],
+                        1400, // 1.4 second interval (slower)
+                        true  // loop through
+                    ),
+                    UIAction::guide(
+                        query.to_string(),
+                        "Welcome to Sorter for Reddit".to_string(),
+                        "Where would you like to start?".to_string(),
+                        vec![
+                            GuideOption { 
+                                key: "r".to_string(), 
+                                label: "Sort a Subreddit".to_string(), 
+                                description: "Find the best posts in a community.".to_string(), 
+                                completion: format!("{}r/", prefix)
+                            },
+                            GuideOption { 
+                                key: "u".to_string(), 
+                                label: "Sort User Content".to_string(), 
+                                description: "Explore and rank a user's posts and comments.".to_string(), 
+                                completion: format!("{}u/", prefix)
+                            },
+                        ]
+                    ),
+                ])
+            })
+        
+
+        
+        // === SUBREDDIT SELECTION: THE UNIFIED NODE ===
+        // This node is now reached from `r/` OR `reddit.com/r/`
+        .at("subreddit_selection")
+            .edge(EdgePattern::Variable("subreddit"), "subreddit_page")
+            .handler(|query, prefix, _ctx| {
+                UIAction::multiple(vec![
+                    UIAction::scrolling_suggestions(
+                        query.to_string(),
+                        vec![
+                            ScrollingSuggestion {
+                                completion: format!("{}programming", prefix),
+                            },
+                            ScrollingSuggestion {
+                                completion: format!("{}askreddit", prefix),
+                            },
+                            ScrollingSuggestion {
+                                completion: format!("{}aww", prefix),
+                            },
+                            ScrollingSuggestion {
+                                completion: format!("{}rust", prefix),
+                            },
+                            ScrollingSuggestion {
+                                completion: format!("{}webdev", prefix),
+                            },
+                        ],
+                        1600, // 1.6 second interval (slower)
+                        true  // loop through
+                    ),
+                    // Live DB-backed suggestions for subreddits as the user types
+                    UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }
+                ])
+            })
+        
+        // === Specific subreddit page ===
+        .at("subreddit_page")
+            .edge(EdgePattern::Literal("/"), "subreddit_slash")
+            .handler(|_query, prefix, ctx| {
+                // Use the new unified subreddit resolution logic
+                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
+                UIAction::ResolveAndDisplaySubreddit {
+                    subreddit,
+                    prefix: prefix.to_string(),
+                }
+            })
+        
+        .at("subreddit_slash")
+            .edge(EdgePattern::Literal("hot"), "subreddit_hot")
+            .edge(EdgePattern::Literal("top"), "subreddit_top")
+            .edge(EdgePattern::Literal("new"), "subreddit_new")
+            .edge(EdgePattern::Literal("comments"), "subreddit_comments")
+            .handler(|query, prefix, ctx| {
+                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
+                UIAction::guide(
+                    query.to_string(), 
+                    format!("What to sort in r/{}?", subreddit), 
+                    "Choose a category to begin sorting.".to_string(), 
+                    vec![
+                        GuideOption { 
+                            key: "hot".to_string(), 
+                            label: "Hot Posts".to_string(), 
+                            description: "Import and sort posts currently on the front page.".to_string(), 
+                            completion: format!("{}hot", prefix) 
+                        },
+                        GuideOption { 
+                            key: "top".to_string(), 
+                            label: "Top Posts".to_string(), 
+                            description: "Import and sort the highest-rated posts.".to_string(), 
+                            completion: format!("{}top", prefix) 
+                        },
+                        GuideOption { 
+                            key: "new".to_string(), 
+                            label: "New Posts".to_string(), 
+                            description: "Import and sort the newest posts.".to_string(), 
+                            completion: format!("{}new", prefix) 
+                        },
+                        GuideOption { 
+                            key: "comments".to_string(), 
+                            label: "All Comments".to_string(), 
+                            description: "Find the best comment across all imported threads.".to_string(), 
+                            completion: format!("{}comments/", prefix) 
+                        },
+                    ]
+                )
+            })
+        
+
+        
+        .at("user_selection")
+            .edge(EdgePattern::Variable("username"), "user_profile")
+            .handler(|query, prefix, _ctx| {
+                UIAction::guide(
+                    query.to_string(),
+                    "User Profile Sorting".to_string(),
+                    "Enter a Reddit username to sort their content.".to_string(),
+                    vec![
+                        GuideOption {
+                            key: "popular".to_string(),
+                            label: "Popular Users".to_string(),
+                            description: "Browse well-known Reddit users.".to_string(),
+                            completion: prefix.to_string(),
+                        },
+                    ]
+                )
+            })
+        
+        // === Subreddit sort types ===
+        .at("subreddit_hot")
+            .handler(|_query, _prefix, ctx| {
+                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
+                UIAction::RenderEntityView {
+                    ns: "reddit.subreddit".to_string(),
+                    pk: subreddit,
+                }
+            })
+        
+        .at("subreddit_top")
+            .handler(|_query, _prefix, ctx| {
+                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
+                UIAction::RenderEntityView {
+                    ns: "reddit.subreddit".to_string(),
+                    pk: subreddit,
+                }
+            })
+        
+        .at("subreddit_new")
+            .handler(|_query, _prefix, ctx| {
+                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
+                UIAction::RenderEntityView {
+                    ns: "reddit.subreddit".to_string(),
+                    pk: subreddit,
+                }
+            })
+        
+        .at("subreddit_comments")
+            .handler(|_query, _prefix, ctx| {
+                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
+                UIAction::RenderEntityView {
+                    ns: "reddit.subreddit".to_string(),
+                    pk: subreddit,
+                }
+            })
+        
+        // === User profile ===
+        .at("user_profile")
+            .handler(|_query, _prefix, ctx| {
+                let username = ctx.get("username").cloned().unwrap_or_default();
+                UIAction::RenderEntityView {
+                    ns: "reddit.user".to_string(),
+                    pk: username,
+                }
+            })
+        
+        // === Build the graph ===
+        .build()
+}
+
+// --- Public API ---
+
+/// Parse a query string and return a UI action
+pub fn parse_reddit_url(query: &str) -> UIAction {
+    let graph = build_reddit_graph();
+    graph.parse(query)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Represents a keystroke action
+    #[derive(Debug, Clone, PartialEq)]
+    enum KeyAction {
+        Type(String),  // Type characters
+        Tab,           // Press tab (accept completion)
+
+    }
+
+    /// Expected state after a keystroke
+    #[derive(Debug, Clone)]
+    enum ExpectedAction {
+        Suggestion { completion: String },
+        ScrollingSuggestions { completions: Vec },
+        Guide { title_contains: String },
+
+        RenderSubreddit { subreddit: String, sort: Option },
+        RenderSubredditComments { subreddit: String },
+        RenderUser { username: String },
+        ResolveSubreddit { subreddit: String, prefix: String },  // New unified subreddit resolution
+        Error { error_type: String },
+        Multiple { expected_actions: Vec },  // Multiple responses with specific expectations
+        MultipleAny,  // Multiple responses (any sub-actions - legacy)
+        DbSuggestions { partial: String, prefix: String },  // Database-backed suggestions
+
+    }
+
+    impl ExpectedAction {
+        fn matches(&self, action: &UIAction) -> bool {
+            match (self, action) {
+                (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {
+                    data.suggestion.as_ref()
+                        .map(|s| s.completion == *completion)
+                        .unwrap_or(false)
+                }
+                (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => {
+                    let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();
+                    *completions == actual_completions
+                }
+                (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => {
+                    title.contains(title_contains)
+                }
+                (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, 
+                 UIAction::RenderEntityView { ns, pk }) => {
+                    ns == "reddit.subreddit" && pk == subreddit
+                }
+                (ExpectedAction::RenderSubredditComments { subreddit }, 
+                 UIAction::RenderEntityView { ns, pk }) => {
+                    ns == "reddit.subreddit" && pk == subreddit
+                }
+                (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {
+                    ns == "reddit.user" && pk == username
+                }
+                (ExpectedAction::ResolveSubreddit { subreddit, prefix }, 
+                 UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {
+                    s == subreddit && p == prefix
+                }
+                (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {
+                    data.error_type == *error_type
+                }
+                (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => {
+                    // Check that all expected actions are present
+                    if expected_actions.len() != actions.len() {
+                        return false;
+                    }
+                    expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {
+                        expected.matches(actual)
+                    })
+                }
+                (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true,
+                (ExpectedAction::DbSuggestions { partial, prefix }, 
+                 UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {
+                    p == partial && pr == prefix
+                }
+
+                _ => false,
+            }
+        }
+    }
+
+    /// Test helper to simulate a sequence of keystrokes
+    fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> {
+        let graph = build_reddit_graph();
+        let mut current_text = String::new();
+        let mut results = Vec::new();
+        
+        for action in actions {
+            match action {
+                KeyAction::Type(text) => {
+                    current_text.push_str(&text);
+                    let result = graph.parse(¤t_text);
+                    results.push((current_text.clone(), result));
+                }
+                KeyAction::Tab => {
+                    // Tab accepts the current suggestion if there is one
+                    let result = graph.parse(¤t_text);
+                    if let UIAction::ShowSuggestions(ref data) = result {
+                        if let Some(ref suggestion) = data.suggestion {
+                            current_text = suggestion.completion.clone();
+                            let new_result = graph.parse(¤t_text);
+                            results.push((current_text.clone(), new_result));
+                        }
+                    }
+                }
+
+            }
+        }
+        
+        results
+    }
+
+    /// Test a flow using declarative (KeyAction, ExpectedAction) tuples
+    fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {
+        let graph = build_reddit_graph();
+        let mut current_text = String::new();
+        
+        println!("\n=== Flow: {} ===", name);
+        
+        for (i, (key_action, expected)) in flow.iter().enumerate() {
+            // Perform the keystroke
+            match key_action {
+                KeyAction::Type(text) => {
+                    current_text.push_str(text);
+                }
+                KeyAction::Tab => {
+                    // Tab accepts the current suggestion
+                    let result = graph.parse(¤t_text);
+                    if let UIAction::ShowSuggestions(data) = result {
+                        if let Some(suggestion) = &data.suggestion {
+                            current_text = suggestion.completion.clone();
+                        }
+                    }
+                }
+            }
+            
+            // Check the result
+            let actual_action = graph.parse(¤t_text);
+            
+            println!("  Step {}: {:?} -> '{}' -> {:?}", 
+                     i + 1, key_action, current_text, actual_action);
+            
+            assert!(
+                expected.matches(&actual_action),
+                "Flow '{}' failed at step {}\n  Expected: {:?}\n  Actual: {:?}\n  Text: '{}'",
+                name, i + 1, expected, actual_action, current_text
+            );
+        }
+        
+        println!("✓ Flow '{}' passed!", name);
+    }
+    
+    /// Legacy helper for backward compatibility (will be removed)
+    fn assert_flow(
+        name: &str,
+        actions: Vec,
+        expected_checks: Vec bool>>,
+    ) {
+        let results = simulate_keystrokes(actions);
+        
+        println!("\n=== Flow: {} ===", name);
+        for (i, (text, action)) in results.iter().enumerate() {
+            println!("  Step {}: '{}' -> {:?}", i + 1, text, action);
+            
+            if i < expected_checks.len() {
+                let check = &expected_checks[i];
+                assert!(
+                    check(text, action),
+                    "Flow '{}' failed at step {} with text '{}' and action {:?}",
+                    name, i + 1, text, action
+                );
+            }
+        }
+        println!("✓ Flow '{}' passed!", name);
+    }
+
+    #[test]
+    fn test_https_reddit_tab_flow() {
+        // Test: typing "https://reddit.com" and pressing tab should give "https://reddit.com/"
+        assert_flow(
+            "HTTPS Reddit with Tab",
+            vec![
+                KeyAction::Type("https://reddit.com".to_string()),
+                KeyAction::Tab,
+            ],
+            vec![
+                Box::new(|text, action| {
+                    // After typing "https://reddit.com", should get a suggestion
+                    text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if 
+                        data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com/").unwrap_or(false)
+                    )
+                }),
+                Box::new(|text, action| {
+                    // After tab, should have "https://reddit.com/" and show guide
+                    text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. })
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_quick_subreddit_flow() {
+        // Test: "r" -> TAB -> "rust" (direct alias flow)
+        assert_flow(
+            "Quick Subreddit Access",
+            vec![
+                KeyAction::Type("r".to_string()),
+                KeyAction::Tab,
+                KeyAction::Type("rust".to_string()),
+            ],
+            vec![
+                Box::new(|text, action| {
+                    // "r" should suggest "r/"
+                    text == "r" && matches!(action, UIAction::ShowSuggestions(data) if 
+                        data.suggestion.as_ref().map(|s| s.completion == "r/").unwrap_or(false)
+                    )
+                }),
+                Box::new(|text, action| {
+                    // After tab, should have "r/" and show subreddit selection
+                    text == "r/" && matches!(action, UIAction::ShowMultiple { .. })
+                }),
+                Box::new(|text, action| {
+                    // "r/rust" should resolve the subreddit
+                    text == "r/rust" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "rust" && prefix == "r/rust")
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_progressive_completion_flow() {
+        // Test progressive typing: "r" -> "re" -> "red" -> "redd" -> "reddit" -> TAB
+        let progressive_actions = vec![
+            KeyAction::Type("r".to_string()),
+            KeyAction::Type("e".to_string()),
+            KeyAction::Type("d".to_string()),
+            KeyAction::Type("d".to_string()),
+            KeyAction::Type("i".to_string()),
+            KeyAction::Type("t".to_string()),
+            KeyAction::Tab,
+        ];
+        
+        let results = simulate_keystrokes(progressive_actions);
+        
+        println!("\n=== Progressive Completion Flow ===");
+        for (i, (text, action)) in results.iter().enumerate() {
+            println!("  '{}' -> {:?}", text, action);
+            
+            // First step: "r" should suggest "r/"
+            if i == 0 && text == "r" {
+                match action {
+                    UIAction::ShowSuggestions(data) => {
+                        assert_eq!(
+                            data.suggestion.as_ref().unwrap().completion,
+                            "r/",
+                            "Should suggest r/ at 'r'"
+                        );
+                    }
+                    _ => panic!("Expected suggestion at 'r'"),
+                }
+            }
+            // Other steps before tab should suggest "reddit.com"
+            else if i > 0 && i < results.len() - 1 {
+                match action {
+                    UIAction::ShowSuggestions(data) => {
+                        assert_eq!(
+                            data.suggestion.as_ref().unwrap().completion,
+                            "reddit.com",
+                            "Should suggest reddit.com at '{}'", text
+                        );
+                    }
+                    _ => panic!("Expected suggestion at '{}'", text),
+                }
+            }
+        }
+        
+        // After tab, should have "reddit.com"
+        let (final_text, _) = results.last().unwrap();
+        assert_eq!(final_text, "reddit.com");
+        println!("✓ Progressive completion flow passed!");
+    }
+
+    #[test]
+    fn test_subreddit_sort_flow() {
+        // Test navigating to a subreddit and choosing a sort option
+        assert_flow(
+            "Subreddit Sort Navigation",
+            vec![
+                KeyAction::Type("reddit.com/r/programming/hot".to_string()),
+            ],
+            vec![
+                Box::new(|text, action| {
+                    text == "reddit.com/r/programming/hot" && 
+                    matches!(action, UIAction::RenderEntityView { ns, pk } 
+                        if ns == "reddit.subreddit" && pk == "programming")
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_user_profile_flow() {
+        // Test navigating to a user profile
+        assert_flow(
+            "User Profile Navigation",
+            vec![
+                KeyAction::Type("reddit.com/u/spez".to_string()),
+            ],
+            vec![
+                Box::new(|text, action| {
+                    text == "reddit.com/u/spez" && 
+                    matches!(action, UIAction::RenderEntityView { ns, pk } if ns == "reddit.user" && pk == "spez")
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_alias_shortcut_flow() {
+        // Test using the "r/" shortcut - now correctly goes directly to subreddit
+        assert_flow(
+            "Alias Shortcut",
+            vec![
+                KeyAction::Type("r/".to_string()),
+                KeyAction::Type("technology".to_string()),
+            ],
+            vec![
+                Box::new(|text, action| {
+                    text == "r/" && matches!(action, UIAction::ShowMultiple { .. })
+                }),
+                Box::new(|text, action| {
+                    text == "r/technology" && 
+                    matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "technology" && prefix == "r/technology")
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_www_prefix_flow() {
+        // Test with www prefix
+        assert_flow(
+            "WWW Prefix",
+            vec![
+                KeyAction::Type("www.reddit.com".to_string()),
+                KeyAction::Tab,
+            ],
+            vec![
+                Box::new(|text, action| {
+                    // Should suggest adding slash
+                    text == "www.reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if 
+                        data.suggestion.as_ref().map(|s| s.completion == "www.reddit.com/").unwrap_or(false)
+                    )
+                }),
+                Box::new(|text, action| {
+                    // After tab, should show guide
+                    text == "www.reddit.com/" && matches!(action, UIAction::ShowMultiple { .. })
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_invalid_path_handling() {
+        // Test that invalid paths show errors
+        assert_flow(
+            "Invalid Path",
+            vec![
+                KeyAction::Type("reddit.com/invalid/path".to_string()),
+            ],
+            vec![
+                Box::new(|text, action| {
+                    text == "reddit.com/invalid/path" && 
+                    matches!(action, UIAction::ShowError(_))
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_complete_user_journey() {
+        // Test a complete user journey: type partial URL, tab complete, navigate to subreddit
+        assert_flow(
+            "Complete User Journey",
+            vec![
+                KeyAction::Type("http".to_string()),
+                KeyAction::Type("s://r".to_string()),
+                KeyAction::Tab,
+                KeyAction::Type("/".to_string()),
+                KeyAction::Type("r/".to_string()),
+                KeyAction::Type("programming".to_string()),
+                KeyAction::Type("/".to_string()),
+                KeyAction::Type("top".to_string()),
+            ],
+            vec![
+                Box::new(|text, action| {
+                    // "http" should suggest "https://"
+                    text == "http" && matches!(action, UIAction::ShowSuggestions(data) if 
+                        data.suggestion.as_ref().map(|s| s.completion == "https://").unwrap_or(false)
+                    )
+                }),
+                Box::new(|text, action| {
+                    // "https://r" should suggest "https://reddit.com"
+                    text == "https://r" && matches!(action, UIAction::ShowSuggestions(data) if 
+                        data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com").unwrap_or(false)
+                    )
+                }),
+                Box::new(|text, action| {
+                    // After tab, should have "https://reddit.com"
+                    text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(_))
+                }),
+                Box::new(|text, action| {
+                    // "https://reddit.com/" should show guide
+                    text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. })
+                }),
+                Box::new(|text, action| {
+                    // "https://reddit.com/r/" should show subreddit selection
+                    text == "https://reddit.com/r/" && matches!(action, UIAction::ShowMultiple { .. })
+                }),
+                Box::new(|text, action| {
+                    // "https://reddit.com/r/programming" should resolve subreddit
+                    text == "https://reddit.com/r/programming" && 
+                    matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } 
+                        if subreddit == "programming" && prefix == "https://reddit.com/r/programming")
+                }),
+                Box::new(|text, action| {
+                    // "https://reddit.com/r/programming/" should show sort options
+                    text == "https://reddit.com/r/programming/" && 
+                    matches!(action, UIAction::ShowStaticGuide { .. })
+                }),
+                Box::new(|text, action| {
+                    // "https://reddit.com/r/programming/top" should render entity view
+                    text == "https://reddit.com/r/programming/top" && 
+                    matches!(action, UIAction::RenderEntityView { ns, pk } 
+                        if ns == "reddit.subreddit" && pk == "programming")
+                }),
+            ],
+        );
+    }
+
+    #[test]
+    fn test_multiple_tab_completions() {
+        // Test multiple tab completions in sequence
+        let actions = vec![
+            KeyAction::Type("h".to_string()),
+            KeyAction::Tab,  // Complete to "https://"
+            KeyAction::Type("r".to_string()),
+            KeyAction::Tab,  // Complete to "https://reddit.com"
+            KeyAction::Type("/".to_string()),
+        ];
+        
+        let results = simulate_keystrokes(actions);
+        
+        println!("\n=== Multiple Tab Completions ===");
+        for (i, (text, _action)) in results.iter().enumerate() {
+            println!("  Step {}: '{}'", i + 1, text);
+        }
+        
+        // Verify the final state
+        assert_eq!(results[1].0, "https://");  // After first tab
+        assert_eq!(results[3].0, "https://reddit.com");  // After second tab
+        assert_eq!(results[4].0, "https://reddit.com/");  // After typing /
+        
+        println!("✓ Multiple tab completions work correctly!");
+    }
+
+    // === CONVENIENCE MACROS FOR CLEANER TESTS ===
+    
+    macro_rules! flow {
+        ($(($key:expr, $expected:expr)),* $(,)?) => {
+            vec![$(($key, $expected)),*]
+        };
+    }
+    
+    macro_rules! type_text {
+        ($text:expr) => {
+            KeyAction::Type($text.to_string())
+        };
+    }
+    
+    macro_rules! suggests {
+        ($completion:expr) => {
+            ExpectedAction::Suggestion { completion: $completion.to_string() }
+        };
+    }
+    
+    macro_rules! renders_subreddit {
+        ($subreddit:expr) => {
+            ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }
+        };
+        ($subreddit:expr, $sort:expr) => {
+            ExpectedAction::RenderSubreddit { 
+                subreddit: $subreddit.to_string(), 
+                sort: Some($sort.to_string()) 
+            }
+        };
+    }
+    
+    macro_rules! renders_subreddit_comments {
+        ($subreddit:expr) => {
+            ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }
+        };
+    }
+    
+    macro_rules! resolves_subreddit {
+        ($subreddit:expr, $prefix:expr) => {
+            ExpectedAction::ResolveSubreddit { 
+                subreddit: $subreddit.to_string(),
+                prefix: $prefix.to_string()
+            }
+        };
+    }
+    
+    macro_rules! shows_guide {
+        ($title_contains:expr) => {
+            ExpectedAction::Guide { title_contains: $title_contains.to_string() }
+        };
+    }
+    
+    macro_rules! multiple {
+        ($($action:expr),* $(,)?) => {
+            ExpectedAction::Multiple { expected_actions: vec![$($action),*] }
+        };
+    }
+    
+    macro_rules! scrolling_suggestions {
+        ($($completion:expr),* $(,)?) => {
+            ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }
+        };
+    }
+    
+    macro_rules! db_suggestions {
+        ($partial:expr, $prefix:expr) => {
+            ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }
+        };
+    }
+
+    // === NEW DECLARATIVE TESTS ===
+
+    #[test]
+    fn test_declarative_https_tab_flow() {
+        test_flow("HTTPS Tab Completion", vec![
+            (KeyAction::Type("https://reddit.com".to_string()), 
+             ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }),
+            (KeyAction::Tab, 
+             multiple![
+                 scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"),
+                 shows_guide!("Welcome to Sorter")
+             ]),
+        ]);
+    }
+
+    #[test]
+    fn test_declarative_quick_subreddit_flow() {
+        test_flow("Quick Subreddit Flow", vec![
+            (KeyAction::Type("r".to_string()), 
+             ExpectedAction::Suggestion { completion: "r/".to_string() }),
+            (KeyAction::Tab, 
+             ExpectedAction::MultipleAny), // r/ shows subreddit selection
+            (KeyAction::Type("rust".to_string()),
+             ExpectedAction::ResolveSubreddit { subreddit: "rust".to_string(), prefix: "r/rust".to_string() }),
+        ]);
+    }
+
+    #[test]
+    fn test_declarative_complete_journey() {
+        test_flow("Complete User Journey", vec![
+            (KeyAction::Type("http".to_string()),
+             ExpectedAction::Suggestion { completion: "https://".to_string() }),
+            (KeyAction::Type("s://r".to_string()),
+             ExpectedAction::Suggestion { completion: "https://reddit.com".to_string() }),
+            (KeyAction::Tab,
+             ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }),
+            (KeyAction::Type("/r/programming/top".to_string()),
+             ExpectedAction::RenderSubreddit { 
+                 subreddit: "programming".to_string(), 
+                 sort: Some("top".to_string()) 
+             }),
+        ]);
+    }
+
+    #[test]
+    fn test_declarative_user_profile() {
+        test_flow("User Profile Navigation", vec![
+            (KeyAction::Type("reddit.com/u/spez".to_string()),
+             ExpectedAction::RenderUser { username: "spez".to_string() }),
+        ]);
+    }
+
+    #[test]
+    fn test_declarative_error_handling() {
+        test_flow("Error Handling", vec![
+            (KeyAction::Type("reddit.com/invalid/path".to_string()),
+             ExpectedAction::Error { error_type: "InvalidPath".to_string() }),
+        ]);
+    }
+
+    #[test]
+    fn test_declarative_progressive_completion() {
+        test_flow("Progressive Completion", vec![
+            (KeyAction::Type("r".to_string()),
+             ExpectedAction::Suggestion { completion: "r/".to_string() }),
+            (KeyAction::Type("e".to_string()),
+             ExpectedAction::Suggestion { completion: "reddit.com".to_string() }),
+            (KeyAction::Type("d".to_string()),
+             ExpectedAction::Suggestion { completion: "reddit.com".to_string() }),
+            (KeyAction::Type("dit".to_string()),
+             ExpectedAction::Suggestion { completion: "reddit.com".to_string() }),
+            (KeyAction::Tab,
+             ExpectedAction::Suggestion { completion: "reddit.com/".to_string() }),
+        ]);
+    }
+
+    #[test]
+    fn test_declarative_subreddit_guide() {
+        test_flow("Subreddit Guide", vec![
+            (KeyAction::Type("reddit.com/r/programming/".to_string()),
+             ExpectedAction::Guide { title_contains: "What to sort".to_string() }),
+        ]);
+    }
+
+    #[test]
+    fn test_clean_macro_example() {
+        // This is what the tests can look like with macros!
+        test_flow("Clean Macro Example", flow![
+            (type_text!("r"), suggests!("r/")),
+            (KeyAction::Tab, ExpectedAction::MultipleAny),
+            (type_text!("rust/hot"), renders_subreddit!("rust", "hot")),
+        ]);
+    }
+
+    #[test]
+    fn test_unified_subreddit_resolution_flow() {
+        // This test demonstrates the new unified behavior:
+        // - r/programming (exact) → tries exact match first
+        // - r/pro (partial) → tries exact match, then suggestions + TAB completion
+        
+        test_flow("Unified Resolution: Exact Match", flow![
+            (type_text!("r/programming"), resolves_subreddit!("programming", "r/programming")),
+        ]);
+        
+        test_flow("Unified Resolution: Partial Match", flow![
+            (type_text!("r/pro"), resolves_subreddit!("pro", "r/pro")),
+        ]);
+        
+        // Both go through the same action type, but dispatcher handles them differently:
+        // - If "programming" exists in DB → shows EntityView immediately
+        // - If "pro" doesn't exist in DB → shows Multiple with:
+        //   1. Suggestions (for TAB completion to best match)  
+        //   2. Selection (for clickable options including import)
+    }
+
+    #[test]
+    fn test_tab_completion_workflow_unified() {
+        // This demonstrates the desired TAB completion behavior:
+        // r/pro + TAB → r/programming (if "programming" is the best DB match)
+        
+        // Note: This test shows the PARSER behavior. The actual TAB completion 
+        // happens in the frontend when it receives the Multiple response containing
+        // both Suggestions (for TAB) and Selection (for click options).
+        
+        let graph = build_reddit_graph();
+        
+        // 1. Parser generates unified action for partial input
+        match graph.parse("r/pro") {
+            UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {
+                assert_eq!(subreddit, "pro");
+                assert_eq!(prefix, "r/pro");
+                println!("✓ Parser correctly identifies 'r/pro' as subreddit resolution");
+            }
+            _ => panic!("Expected ResolveAndDisplaySubreddit for 'r/pro'"),
+        }
+        
+        // 2. When dispatcher runs (in real app), it will return Multiple response with:
+        //    - Suggestions: { completion: "r/programming" } (for TAB)
+        //    - Selection: [ "Import r/pro", "r/programming", ... ] (for clicks)
+        
+        println!("✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)");
+    }
+
+    #[test]
+    fn test_ultra_clean_user_journey() {
+        test_flow("Ultra Clean User Journey", flow![
+            (type_text!("h"), suggests!("https://")),
+            (type_text!("ttps://r"), suggests!("https://reddit.com")), 
+            (KeyAction::Tab, suggests!("https://reddit.com/")),
+            (type_text!("/u/spez"), ExpectedAction::RenderUser { username: "spez".to_string() }),
+        ]);
+    }
+
+    // === TESTS FROM parser.tdsl ===
+
+    #[test]
+    fn test_tdsl_basic_reddit_progression() {
+        // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps
+        test_flow("TDSL Basic Reddit Progression", flow![
+            (type_text!("r"), suggests!("r/")),
+            (type_text!("e"), suggests!("reddit.com")),
+            (type_text!("d"), suggests!("reddit.com")),
+            (type_text!("d"), suggests!("reddit.com")),
+            (type_text!("i"), suggests!("reddit.com")),
+            (type_text!("t"), suggests!("reddit.com")),
+            (type_text!("."), suggests!("reddit.com")),
+            (type_text!("c"), suggests!("reddit.com")),
+            (type_text!("o"), suggests!("reddit.com")),
+            (KeyAction::Tab, suggests!("reddit.com/")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_reddit_com_slash_infographic() {
+        // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}
+        test_flow("TDSL Reddit.com/ Infographic", flow![
+            (type_text!("reddit.com/"), ExpectedAction::MultipleAny),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_subreddit_selection() {
+        // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}
+        test_flow("TDSL Subreddit Selection", flow![
+            (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_subreddit_view() {
+        // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}
+        test_flow("TDSL Subreddit View", flow![
+            (type_text!("reddit.com/r/programming"), resolves_subreddit!("programming", "reddit.com/r/programming")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_subreddit_slash_infographic() {
+        // reddit.com/r/{sub}/ -> {show infographic or something}
+        test_flow("TDSL Subreddit Slash Infographic", flow![
+            (type_text!("reddit.com/r/programming/"), shows_guide!("What to sort")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_subreddit_comments() {
+        // reddit.com/r/{sub}/comments/{randomly chose comment from sql}
+        test_flow("TDSL Subreddit Comments", flow![
+            (type_text!("reddit.com/r/programming/comments"), renders_subreddit_comments!("programming")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_h_to_https() {
+        // h->https:// (show supported domains)
+        test_flow("TDSL H to HTTPS", flow![
+            (type_text!("h"), suggests!("https://")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_composable_https_reddit() {
+        // https://r->https://reddit.com/
+        test_flow("TDSL Composable HTTPS Reddit", flow![
+            (type_text!("https://r"), suggests!("https://reddit.com")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_composable_https_www() {
+        // https://w->https://www.
+        test_flow("TDSL Composable HTTPS WWW", flow![
+            (type_text!("https://w"), suggests!("https://www.")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_composable_https_www_reddit() {
+        // https://www.r->https://www.reddit.com/
+        test_flow("TDSL Composable HTTPS WWW Reddit", flow![
+            (type_text!("https://www.r"), suggests!("https://www.reddit.com")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_full_composable_chain() {
+        // Complete chain showing composability: h -> https:// -> https://www.reddit.com
+        test_flow("TDSL Full Composable Chain Step 1", flow![
+            (type_text!("h"), suggests!("https://")),
+        ]);
+        
+        test_flow("TDSL Full Composable Chain Step 2", flow![
+            (type_text!("https://w"), suggests!("https://www.")),
+        ]);
+        
+        test_flow("TDSL Full Composable Chain Step 3", flow![
+            (type_text!("https://www.r"), suggests!("https://www.reddit.com")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_progressive_reddit_paths() {
+        // Test various reddit paths work as expected
+        test_flow("TDSL Progressive Reddit Paths", flow![
+            (type_text!("reddit.com"), suggests!("reddit.com/")),
+            (KeyAction::Tab, ExpectedAction::MultipleAny),
+        ]);
+    }
+
+    #[test] 
+    fn test_tdsl_subreddit_sorting_options() {
+        // Test that subreddit sorting options work as described
+        test_flow("TDSL Subreddit Sorting", flow![
+            (type_text!("reddit.com/r/rust/hot"), renders_subreddit!("rust", "hot")),
+        ]);
+        
+        test_flow("TDSL Subreddit Top", flow![
+            (type_text!("reddit.com/r/rust/top"), renders_subreddit!("rust", "top")),
+        ]);
+        
+        test_flow("TDSL Subreddit New", flow![
+            (type_text!("reddit.com/r/rust/new"), renders_subreddit!("rust", "new")),
+        ]);
+    }
+
+    #[test]
+    fn test_tdsl_all_reddit_prefixes() {
+        // Test all the prefixes mentioned in parser.tdsl work
+        // "r" now suggests "r/", all others suggest "reddit.com"
+        let prefixes_to_reddit = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"];
+        
+        // Test "r" separately since it now suggests "r/"
+        test_flow("TDSL Prefix: r", flow![
+            (type_text!("r"), suggests!("r/")),
+        ]);
+        
+        for prefix in prefixes_to_reddit {
+            test_flow(&format!("TDSL Prefix: {}", prefix), flow![
+                (type_text!(prefix), suggests!("reddit.com")),
+            ]);
+        }
+    }
+
+    #[test]
+    fn test_tdsl_protocol_combinations() {
+        // Test various protocol combinations from parser.tdsl
+        let test_cases = vec![
+            ("http://r", "http://reddit.com"),
+            ("https://r", "https://reddit.com"),
+            ("www.r", "www.reddit.com"),
+            ("https://www.r", "https://www.reddit.com"),
+            ("http://www.r", "http://www.reddit.com"),
+        ];
+        
+        for (input, expected) in test_cases {
+            test_flow(&format!("TDSL Protocol: {}", input), flow![
+                (type_text!(input), suggests!(expected)),
+            ]);
+        }
+    }
+
+    #[test]
+    fn test_tdsl_edge_case_completions() {
+        // Test edge cases mentioned in parser.tdsl
+        test_flow("TDSL Reddit.com completion", flow![
+            (type_text!("reddit.com"), suggests!("reddit.com/")),
+        ]);
+        
+        // Test that typing full reddit.com suggests the slash
+        test_flow("TDSL Full domain completion", flow![
+            (type_text!("reddit.com"), suggests!("reddit.com/")),
+            (KeyAction::Tab, ExpectedAction::MultipleAny),
+        ]);
+    }
+
+    #[test]
+    fn test_real_user_trace_2025_08_07_fixed() {
+        // Based on actual WebSocket trace from 2025-08-07T01:35:46Z
+        // This test shows the CORRECT behavior after fixing the protocol preservation bug
+        test_flow("Real User Trace: Progressive Typing with Tab Completions (Fixed)", flow![
+            // User started typing "h"
+            (type_text!("h"), suggests!("https://")),
+            
+            // User continued to "ht" 
+            (type_text!("t"), suggests!("https://")),
+            
+            // User finished typing "https://" (trace shows full protocol)
+            (type_text!("tps://"), suggests!("https://")),
+            
+            // User started typing "r" after protocol
+            (type_text!("r"), suggests!("https://reddit.com")),
+            
+            // User continued typing "re"
+            (type_text!("e"), suggests!("https://reddit.com")),
+            
+            // User typed out or completed "https://reddit.com"
+            (type_text!("ddit.com"), suggests!("https://reddit.com/")),
+            
+            // User accepted completion to "https://reddit.com/"
+            // NOW suggestions should preserve the https:// protocol
+            (KeyAction::Tab, multiple![
+                scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"),  // This is the key fix!
+                shows_guide!("Welcome to Sorter")
+            ]),
+        ]);
+    }
+
+    #[test]
+    fn test_protocol_preservation_bug_fix() {
+        // This test specifically verifies the fix for the protocol preservation bug
+        test_flow("Protocol Preservation: HTTPS Reddit Homepage", flow![
+            (type_text!("https://reddit.com/"), multiple![
+                scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"),  // Should preserve https://
+                shows_guide!("Welcome to Sorter")
+            ]),
+        ]);
+        
+        // Test that subreddit selection preserves protocol
+        test_flow("Protocol Preservation: HTTPS Subreddit Selection", flow![
+            (type_text!("https://reddit.com/r/"), multiple![
+                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://
+                db_suggestions!("https://reddit.com/r/", "https://reddit.com/r/")
+            ]),
+        ]);
+        
+        // Test with different protocols
+        test_flow("Protocol Preservation: HTTP", flow![
+            (type_text!("http://reddit.com/"), multiple![
+                scrolling_suggestions!("http://reddit.com/r/", "http://reddit.com/u/"),  // Should preserve http://
+                shows_guide!("Welcome to Sorter")
+            ]),
+        ]);
+        
+        test_flow("Protocol Preservation: WWW", flow![
+            (type_text!("www.reddit.com/"), multiple![
+                scrolling_suggestions!("www.reddit.com/r/", "www.reddit.com/u/"),  // Should preserve www.
+                shows_guide!("Welcome to Sorter")
+            ]),
+        ]);
+        
+        // Test that plain reddit.com still works
+        test_flow("Protocol Preservation: Plain Domain", flow![
+            (type_text!("reddit.com/"), multiple![
+                scrolling_suggestions!("reddit.com/r/", "reddit.com/u/"),  // No protocol prefix
+                shows_guide!("Welcome to Sorter")
+            ]),
+        ]);
+    }
+
+    #[test]
+    fn test_user_navigation_pattern() {
+        // Models how users actually navigate: type → tab → click suggestion → end up at destination
+        // This captures the "jump" from https://reddit.com/ to reddit.com/r/ seen in the trace
+        test_flow("User Navigation: Protocol to Domain", flow![
+            // User types and gets to homepage
+            (type_text!("https://reddit.com/"), ExpectedAction::MultipleAny),
+        ]);
+        
+        // Then they navigate (perhaps clicking a suggestion) to subreddit selection
+        test_flow("User Navigation: Click to Subreddit", flow![
+            // This is where they ended up - the suggestion in the guide probably said "reddit.com/r/"
+            (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny),
+        ]);
+    }
+
+    #[test]
+    fn test_progressive_typing_pattern() {
+        // Based on the trace pattern - users often type character by character
+        // This tests the exact sequence of suggestions they would see
+        test_flow("Progressive Typing Pattern", flow![
+            (type_text!("h"), suggests!("https://")),
+            (type_text!("t"), suggests!("https://")), // Still suggests https://
+            (type_text!("t"), suggests!("https://")), // ht -> htt, still suggests https://
+            (type_text!("p"), suggests!("https://")), // http should still suggest https://
+            (type_text!("s"), suggests!("https://")), // https should suggest https://
+            (type_text!("://"), suggests!("https://")), // Even complete protocol still suggests itself
+        ]);
+    }
+
+    #[test]  
+    fn test_tab_completion_workflow() {
+        // Test what happens when user uses tab completions strategically
+        test_flow("Strategic Tab Completion Workflow", flow![
+            // Start typing, get suggestion
+            (type_text!("h"), suggests!("https://")),
+            
+            // Accept suggestion with tab - this should move us to "https://"
+            (KeyAction::Tab, suggests!("https://")), // After tab, we're at "https://" which still suggests itself
+            
+            // Start typing reddit
+            (type_text!("r"), suggests!("https://reddit.com")),
+            
+            // Accept reddit suggestion  
+            (KeyAction::Tab, suggests!("https://reddit.com/")),
+            
+            // Accept final suggestion to get to homepage
+            (KeyAction::Tab, ExpectedAction::MultipleAny),
+        ]);
+    }
+
+    // Keep the original tests as well
+    #[test]
+    fn test_reddit_prefix_autocomplete() {
+        let graph = build_reddit_graph();
+        
+        // "r" now suggests "r/", others suggest "reddit.com"
+        match graph.parse("r") {
+            UIAction::ShowSuggestions(data) => {
+                assert_eq!(data.suggestion.as_ref().unwrap().completion, "r/");
+                println!("✓ 'r' → r/");
+            }
+            _ => panic!("Expected suggestion for 'r'"),
+        }
+        
+        // Test other prefixes that should suggest "reddit.com"
+        let prefixes = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"];
+        
+        for prefix in prefixes {
+            match graph.parse(prefix) {
+                UIAction::ShowSuggestions(data) => {
+                    assert_eq!(data.suggestion.as_ref().unwrap().completion, "reddit.com");
+                    println!("✓ '{}' → reddit.com", prefix);
+                }
+                _ => panic!("Expected suggestion for '{}'", prefix),
+            }
+        }
+    }
+    
+    #[test]
+    fn test_protocol_composition() {
+        let graph = build_reddit_graph();
+        
+        // Test protocol + reddit compositions
+        let tests = vec![
+            ("https://r", "https://reddit.com"),
+            ("https://re", "https://reddit.com"),
+            ("https://reddit", "https://reddit.com"),
+            ("https://www.r", "https://www.reddit.com"),
+            ("https://www.reddit", "https://www.reddit.com"),
+            ("http://r", "http://reddit.com"),
+            ("www.r", "www.reddit.com"),
+        ];
+        
+        for (input, expected) in tests {
+            match graph.parse(input) {
+                UIAction::ShowSuggestions(data) => {
+                    assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);
+                    println!("✓ '{}' → {}", input, expected);
+                }
+                _ => panic!("Expected suggestion for '{}'", input),
+            }
+        }
+    }
+    
+    #[test]
+    fn test_alias_and_full_paths() {
+        let graph = build_reddit_graph();
+        
+        // reddit.com/ should show guide, r/ should show subreddit selection
+        match graph.parse("reddit.com/") {
+            UIAction::ShowMultiple { actions } => {
+                let has_guide = actions.iter()
+                    .any(|a| matches!(a, UIAction::ShowStaticGuide { .. }));
+                assert!(has_guide, "Path 'reddit.com/' should show guide");
+                println!("✓ 'reddit.com/' shows Reddit root guide");
+            }
+            _ => panic!("Expected Multiple action for 'reddit.com/'"),
+        }
+        
+        match graph.parse("r/") {
+            UIAction::ShowMultiple { actions } => {
+                let has_scrolling_suggestions = actions.iter()
+                    .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));
+                let has_db_suggestions = actions.iter()
+                    .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. }));
+                assert!(has_scrolling_suggestions && has_db_suggestions, 
+                       "Path 'r/' should show scrolling suggestions and DB suggestions");
+                println!("✓ 'r/' shows subreddit selection");
+            }
+            _ => panic!("Expected Multiple action for 'r/'"),
+        }
+    }
+    
+    #[test]
+    fn test_deep_navigation() {
+        let graph = build_reddit_graph();
+        
+        // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit
+        match graph.parse("reddit.com/r/rust") {
+            UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {
+                assert_eq!(subreddit, "rust");
+                assert_eq!(prefix, "reddit.com/r/rust");
+                println!("✓ reddit.com/r/rust recognized");
+            }
+            _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit"),
+        }
+        
+        // Test with alias (r/ goes directly to subreddit, no double r/)
+        match graph.parse("r/programming") {
+            UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {
+                assert_eq!(subreddit, "programming");
+                assert_eq!(prefix, "r/programming");
+                println!("✓ r/programming (alias) recognized");
+            }
+            _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit via alias"),
+        }
+    }
+}
diff --git a/legacy/ranking.rs b/legacy/ranking.rs
new file mode 100644
index 0000000000000000000000000000000000000000..34241cf6bf13117b3ad3262761eb7f7b48b54d80
--- /dev/null
+++ b/legacy/ranking.rs
@@ -0,0 +1,393 @@
+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  cat server/src/ranking.rs 
+use std::collections::{HashMap, HashSet};
+
+use crate::path_types::ItemId;
+use crate::reducer::GroupState;
+
+#[derive(Debug, Clone)]
+pub struct RankedItem {
+    pub item: ItemId,
+    pub score: f64,
+}
+
+/// Compute connected components over the voted-pairs graph (treated as undirected).
+///
+/// Returns:
+/// - `components`: each component is a sorted list of node indices, excluding isolates.
+/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).
+pub fn connected_components_from_voted_pairs(
+    n: usize,
+    voted_pairs: impl Iterator,
+) -> (Vec>, Vec) {
+    let mut adj: Vec> = vec![Vec::new(); n];
+    for (a, b) in voted_pairs {
+        if a >= n || b >= n || a == b {
+            continue;
+        }
+        adj[a].push(b);
+        adj[b].push(a);
+    }
+
+    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();
+    isolates.sort();
+
+    let mut seen = vec![false; n];
+    for &i in &isolates {
+        seen[i] = true;
+    }
+
+    let mut comps: Vec> = Vec::new();
+    for i in 0..n {
+        if seen[i] {
+            continue;
+        }
+        let mut stack = vec![i];
+        seen[i] = true;
+        let mut comp: Vec = Vec::new();
+        while let Some(x) = stack.pop() {
+            comp.push(x);
+            for &y in &adj[x] {
+                if !seen[y] {
+                    seen[y] = true;
+                    stack.push(y);
+                }
+            }
+        }
+        comp.sort();
+        comps.push(comp);
+    }
+
+    (comps, isolates)
+}
+
+/// Compute rank centrality scores for a GroupState.
+/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing
+/// an O(E) multiply per iteration.
+pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {
+    if !group.dirty && !group.cached_scores.is_empty() {
+        return;
+    }
+
+    let n = group.idx_to_item.len();
+    if n == 0 {
+        group.cached_scores = vec![];
+        group.dirty = false;
+        return;
+    }
+    if n == 1 {
+        group.cached_scores = vec![1.0];
+        group.dirty = false;
+        return;
+    }
+
+    let scores = compute_scores_from_edges(
+        n,
+        group.edges.iter().map(|(&k, &w)| (k, w)),
+        max_iters,
+        tol,
+    );
+    group.cached_scores = scores;
+    group.dirty = false;
+}
+
+pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {
+    compute_group_ranking(group, max_iters, tol);
+    let mut items: Vec = group
+        .idx_to_item
+        .iter()
+        .enumerate()
+        .map(|(i, item)| RankedItem {
+            item: item.clone(),
+            score: *group.cached_scores.get(i).unwrap_or(&0.0),
+        })
+        .collect();
+
+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
+    items
+}
+
+fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {
+    if n == 0 {
+        return vec![];
+    }
+    if n == 1 {
+        return vec![1.0];
+    }
+
+    // Collect raw edges into a map for pairwise normalization.
+    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();
+    for ((src, dst), w) in edges {
+        if src >= n || dst >= n || w <= 0.0 {
+            continue;
+        }
+        *raw.entry((src, dst)).or_insert(0.0) += w;
+    }
+
+    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).
+    // This ensures repeated votes on the same pair don't inflate influence
+    // beyond what the ratio implies.
+    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();
+    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();
+    for (i, j) in keys {
+        if normalized.contains_key(&(i, j)) {
+            continue;
+        }
+        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);
+        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);
+        let total = w_ij + w_ji;
+        if total <= 0.0 {
+            continue;
+        }
+        normalized.insert((i, j), w_ij / total);
+        if w_ji > 0.0 {
+            normalized.insert((j, i), w_ji / total);
+        }
+    }
+
+    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):
+    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared
+    //   P_ii = 1 - (1/d_max) * Σ_k A_ik
+    // where d_i is the *degree* (number of distinct neighbors compared) and
+    // d_max = max_i d_i. Using the unweighted degree — not the sum of
+    // pairwise-normalized weights — is what guarantees aperiodicity: it
+    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree
+    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous
+    // loss). Without this, regular comparison graphs (e.g. a pure star at
+    // ratio 2:1) produce a bipartite chain that oscillates instead of
+    // converging — see issue #146.
+    let mut out_edges: Vec> = vec![Vec::new(); n];
+    let mut neighbors: Vec> = vec![HashSet::new(); n];
+
+    for ((src, dst), w) in &normalized {
+        out_edges[*src].push((*dst, *w));
+        neighbors[*src].insert(*dst);
+        neighbors[*dst].insert(*src);
+    }
+
+    let weight_sum: Vec = out_edges
+        .iter()
+        .map(|es| es.iter().map(|(_, w)| *w).sum())
+        .collect();
+    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);
+    if d_max == 0 {
+        return vec![1.0 / n as f64; n];
+    }
+    let d_max_f = d_max as f64;
+
+    let mut scores = vec![1.0 / n as f64; n];
+    let mut next = vec![0.0f64; n];
+
+    for _ in 0..max_iters {
+        next.fill(0.0);
+        for i in 0..n {
+            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;
+            next[i] += scores[i] * stay_prob;
+
+            if out_edges[i].is_empty() {
+                continue;
+            }
+            for &(dst, w) in &out_edges[i] {
+                next[dst] += scores[i] * (w / d_max_f);
+            }
+        }
+
+        let diff: f64 = scores
+            .iter()
+            .zip(next.iter())
+            .map(|(a, b)| (a - b).abs())
+            .sum();
+
+        scores.clone_from_slice(&next);
+        if diff < tol {
+            break;
+        }
+    }
+
+    let sum: f64 = scores.iter().sum();
+    if sum.is_finite() && sum > 0.0 {
+        for s in &mut scores {
+            *s /= sum;
+        }
+    }
+    scores
+}
+
+/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.
+///
+/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.
+pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {
+    if idxs.is_empty() {
+        return vec![];
+    }
+
+    // Map original idx -> compact idx [0..m)
+    let mut map: HashMap = HashMap::with_capacity(idxs.len());
+    for (j, &i) in idxs.iter().enumerate() {
+        map.insert(i, j);
+    }
+
+    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {
+        let s = *map.get(&src)?;
+        let d = *map.get(&dst)?;
+        Some(((s, d), w))
+    });
+
+    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);
+
+    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).
+    let mut items: Vec = idxs
+        .iter()
+        .enumerate()
+        .filter_map(|(j, &orig)| {
+            let item = group.idx_to_item.get(orig)?.clone();
+            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })
+        })
+        .collect();
+
+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
+    items
+}
+
+pub fn group_summary_scores(
+    group: &mut GroupState,
+    max_iters: usize,
+    tol: f64,
+) -> HashMap {
+    ranked_items(group, max_iters, tol)
+        .into_iter()
+        .map(|r| (r.item, r.score))
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::reducer::VoteData;
+
+    fn mk_group() -> GroupState {
+        GroupState::new()
+    }
+
+    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {
+        use crate::path_types::ItemId;
+        VoteData {
+            ts,
+            a: ItemId::parse(a).unwrap(),
+            b: ItemId::parse(b).unwrap(),
+            ratio_left: l,
+            ratio_right: r,
+            body: "because".to_string(),
+            principal: "test".to_string(),
+            delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
+            thread_tag: "untagged".to_string(),
+        }
+    }
+
+    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).
+    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the
+    /// chain was bipartite; power iteration oscillated and returned the
+    /// uniform initial distribution after an even number of steps. Using the
+    /// paper's degree-based d_max gives every node a positive self-loop and
+    /// the chain converges to the correct stationary distribution.
+    #[test]
+    fn star_topology_winner_at_top_via_subset() {
+        let mut g = mk_group();
+        g.apply_vote(vote(1, "zebra", "alpha", 2, 1));
+        g.apply_vote(vote(2, "zebra", "beta", 2, 1));
+
+        let mut items: Vec<(usize, String)> = g
+            .idx_to_item
+            .iter()
+            .enumerate()
+            .map(|(i, it)| (i, it.as_str().to_string()))
+            .collect();
+        items.sort_by(|a, b| a.1.cmp(&b.1));
+        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();
+
+        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);
+        for r in &ranked {
+            eprintln!("{}: {}", r.item.as_str(), r.score);
+        }
+        assert_eq!(
+            ranked[0].item.as_str(),
+            "https://slug.social/zebra",
+            "zebra won both votes and should rank #1"
+        );
+    }
+
+    #[test]
+    fn group_ranking_cache_dirty_flow() {
+        let mut g = mk_group();
+        assert!(g.dirty);
+
+        g.apply_vote(vote(1, "a", "b", 3, 1));
+        assert!(g.dirty);
+        assert!(g.cached_scores.is_empty());
+
+        compute_group_ranking(&mut g, 10000, 1e-8);
+        assert!(!g.dirty);
+        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());
+
+        // Recomputing when not dirty should be a no-op.
+        let before = g.cached_scores.clone();
+        compute_group_ranking(&mut g, 10000, 1e-8);
+        assert_eq!(before, g.cached_scores);
+    }
+
+    #[test]
+    fn connected_components_split_disconnected_pairs() {
+        let mut g = mk_group();
+        // Two disconnected edges: (a,b) and (c,d)
+        g.apply_vote(vote(1, "a", "b", 3, 1));
+        g.apply_vote(vote(2, "c", "d", 3, 1));
+
+        let n = g.idx_to_item.len();
+        let (mut comps, isolates) =
+            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());
+        assert!(isolates.is_empty());
+        // Order-independent: sort components by their item names for stable assert.
+        comps.sort_by_key(|c| {
+            c.iter()
+                .map(|&i| g.idx_to_item[i].clone())
+                .collect::>()
+        });
+        assert_eq!(comps.len(), 2);
+        let comp0 = comps[0]
+            .iter()
+            .map(|&i| g.idx_to_item[i].as_str())
+            .collect::>();
+        let comp1 = comps[1]
+            .iter()
+            .map(|&i| g.idx_to_item[i].as_str())
+            .collect::>();
+        assert_eq!(comp0, vec!["https://slug.social/a", "https://slug.social/b"]);
+        assert_eq!(comp1, vec!["https://slug.social/c", "https://slug.social/d"]);
+    }
+
+    #[test]
+    fn subset_ranking_ranks_within_component_only() {
+        let mut g = mk_group();
+        g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b
+        g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c
+
+        let (comps, _) =
+            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());
+        assert_eq!(comps.len(), 2);
+
+        // Rank each component and ensure winner is first within that component.
+        for comp in comps {
+            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);
+            assert_eq!(ranked.len(), 2);
+            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();
+            if names.contains(&"https://slug.social/a") {
+                assert_eq!(names[0], "https://slug.social/a");
+            } else {
+                assert_eq!(names[0], "https://slug.social/d");
+            }
+        }
+    }
+}
+
+
+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  
+
diff --git a/legacy/reddit.rs b/legacy/reddit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..da27f6f76482dd27d44cad9d7b844972bf042e64
--- /dev/null
+++ b/legacy/reddit.rs
@@ -0,0 +1,420 @@
+use governor::{Quota, RateLimiter, Jitter};
+use nonzero_ext::nonzero;
+use reqwest::{Client, StatusCode};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use std::time::Duration;
+use anyhow::{Result, Context, anyhow};
+
+/// Reddit API client with built-in rate limiting
+pub struct RedditClient {
+    client: Client,
+    limiter: Arc,
+    user_agent: String,
+}
+
+impl RedditClient {
+    /// Create a new Reddit client with rate limiting
+    /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps
+    /// We'll be conservative and use 50 requests per minute
+    pub fn new() -> Self {
+        // Create rate limiter: 50 requests per minute
+        let quota = Quota::per_minute(nonzero!(50u32));
+        let limiter = Arc::new(RateLimiter::direct(quota));
+        
+        // Create HTTP client with timeout
+        let client = Client::builder()
+            .timeout(Duration::from_secs(30))
+            .build()
+            .expect("Failed to create HTTP client");
+        
+        // Reddit requires a unique user agent
+        let user_agent = format!(
+            "rust:sorter:v{} (by /u/hourLong_arnould)",
+            env!("CARGO_PKG_VERSION")
+        );
+        
+        Self {
+            client,
+            limiter,
+            user_agent,
+        }
+    }
+    
+    /// Fetch hot posts from a subreddit
+    pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result {
+        // Wait for rate limiter
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!("https://www.reddit.com/r/{}/hot.json?limit={}", subreddit, limit);
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Fetch top posts from a subreddit
+    pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!(
+            "https://www.reddit.com/r/{}/top.json?limit={}&t={}",
+            subreddit, limit, time_period
+        );
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Fetch new posts from a subreddit
+    pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!("https://www.reddit.com/r/{}/new.json?limit={}", subreddit, limit);
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Fetch a specific post and its comments
+    pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result> {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!(
+            "https://www.reddit.com/r/{}/comments/{}.json",
+            subreddit, post_id
+        );
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        match response.status() {
+            StatusCode::OK => {
+                let listings: Vec = response
+                    .json()
+                    .await
+                    .context("Failed to parse Reddit response")?;
+                Ok(listings)
+            }
+            StatusCode::TOO_MANY_REQUESTS => {
+                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+            }
+            StatusCode::NOT_FOUND => {
+                Err(anyhow!("Post not found: r/{}/comments/{}", subreddit, post_id))
+            }
+            status => {
+                Err(anyhow!("Reddit API error: {}", status))
+            }
+        }
+    }
+    
+    /// Fetch user profile (posts and comments)
+    pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!(
+            "https://www.reddit.com/user/{}/{}.json?limit={}",
+            username, content_type, limit
+        );
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Handle Reddit API response with proper error checking
+    async fn handle_response(&self, response: reqwest::Response) -> Result {
+        match response.status() {
+            StatusCode::OK => {
+                let listing: RedditListingResponse = response
+                    .json()
+                    .await
+                    .context("Failed to parse Reddit response")?;
+                Ok(listing)
+            }
+            StatusCode::TOO_MANY_REQUESTS => {
+                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+            }
+            StatusCode::NOT_FOUND => {
+                Err(anyhow!("Reddit resource not found"))
+            }
+            StatusCode::FORBIDDEN => {
+                Err(anyhow!("Access forbidden. The subreddit may be private."))
+            }
+            status => {
+                Err(anyhow!("Reddit API error: {}", status))
+            }
+        }
+    }
+}
+
+// Reddit API response types
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingResponse {
+    pub kind: String,
+    pub data: RedditListingData,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingData {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub after: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub before: Option,
+    pub children: Vec,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub modhash: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditThing {
+    pub kind: String,
+    pub data: RedditThingData,
+}
+
+/// Reddit's "Thing" data can be either a post, comment, or other types
+/// We use untagged enum to handle different data structures
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditThingData {
+    Post(RedditPost),
+    Comment(RedditComment),
+    // For things we don't care about yet (like "more" comments)
+    Other(serde_json::Value),
+}
+
+/// Reddit post data with serde handling all the parsing
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditPost {
+    pub id: String,
+    pub name: String, // Full name (t3_xxx)
+    #[serde(default = "default_untitled")]
+    pub title: String,
+    #[serde(default = "default_deleted_user")]
+    pub author: String,
+    pub subreddit: String,
+    #[serde(default)]
+    pub url: String,
+    #[serde(default)]
+    pub permalink: String,
+    #[serde(default, deserialize_with = "deserialize_selftext")]
+    pub selftext: Option,
+    #[serde(default)]
+    pub score: i64,
+    #[serde(default)]
+    pub num_comments: i64,
+    #[serde(default)]
+    pub created_utc: f64,
+    #[serde(default, deserialize_with = "deserialize_thumbnail")]
+    pub thumbnail: Option,
+    #[serde(default)]
+    pub is_video: bool,
+    #[serde(default)]
+    pub is_self: bool,
+    // Additional useful fields
+    #[serde(default)]
+    pub ups: i64,
+    #[serde(default)]
+    pub downs: i64,
+    #[serde(default)]
+    pub upvote_ratio: f64,
+    #[serde(default)]
+    pub over_18: bool,
+    #[serde(default)]
+    pub spoiler: bool,
+    #[serde(default)]
+    pub stickied: bool,
+    #[serde(default)]
+    pub locked: bool,
+    #[serde(default)]
+    pub distinguished: Option,
+}
+
+/// Reddit comment data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditComment {
+    pub id: String,
+    pub name: String, // Full name (t1_xxx)
+    #[serde(default = "default_deleted_user")]
+    pub author: String,
+    #[serde(default)]
+    pub body: String,
+    #[serde(default)]
+    pub score: i64,
+    #[serde(default)]
+    pub created_utc: f64,
+    #[serde(default)]
+    pub parent_id: String,
+    #[serde(default)]
+    pub permalink: String,
+    #[serde(default)]
+    pub depth: i32,
+    // Additional useful fields
+    #[serde(default)]
+    pub ups: i64,
+    #[serde(default)]
+    pub downs: i64,
+    #[serde(default)]
+    pub edited: RedditEditStatus,
+    #[serde(default)]
+    pub stickied: bool,
+    #[serde(default)]
+    pub distinguished: Option,
+    #[serde(default)]
+    pub is_submitter: bool,
+    #[serde(default)]
+    pub collapsed: bool,
+    #[serde(default)]
+    pub controversiality: i32,
+}
+
+/// Reddit's edit status - can be false or a timestamp
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditEditStatus {
+    NotEdited(bool),
+    EditedAt(f64),
+}
+
+impl Default for RedditEditStatus {
+    fn default() -> Self {
+        RedditEditStatus::NotEdited(false)
+    }
+}
+
+// Helper functions for serde defaults
+fn default_untitled() -> String {
+    "Untitled".to_string()
+}
+
+fn default_deleted_user() -> String {
+    "[deleted]".to_string()
+}
+
+// Custom deserializer for selftext (empty strings should be None)
+fn deserialize_selftext<'de, D>(deserializer: D) -> Result, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let s: Option = Option::deserialize(deserializer)?;
+    Ok(s.filter(|text| !text.is_empty()))
+}
+
+// Custom deserializer for thumbnail (filter out "self", "default", empty)
+fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let s: Option = Option::deserialize(deserializer)?;
+    Ok(s.filter(|thumb| {
+        !thumb.is_empty() && thumb != "self" && thumb != "default" && thumb != "nsfw" && thumb != "spoiler"
+    }))
+}
+
+// Helper methods for extracting typed data from Things
+impl RedditThing {
+    /// Try to get this Thing as a Post
+    pub fn as_post(&self) -> Option<&RedditPost> {
+        if self.kind != "t3" {
+            return None;
+        }
+        match &self.data {
+            RedditThingData::Post(post) => Some(post),
+            _ => None,
+        }
+    }
+    
+    /// Try to get this Thing as a Comment
+    pub fn as_comment(&self) -> Option<&RedditComment> {
+        if self.kind != "t1" {
+            return None;
+        }
+        match &self.data {
+            RedditThingData::Comment(comment) => Some(comment),
+            _ => None,
+        }
+    }
+    
+    /// Check if this is a "more comments" indicator
+    pub fn is_more_comments(&self) -> bool {
+        self.kind == "more"
+    }
+}
+
+// Helper methods for working with listing responses
+impl RedditListingResponse {
+    /// Extract all posts from this listing
+    pub fn get_posts(&self) -> Vec<&RedditPost> {
+        self.data.children.iter()
+            .filter_map(|thing| thing.as_post())
+            .collect()
+    }
+    
+    /// Extract all comments from this listing
+    pub fn get_comments(&self) -> Vec<&RedditComment> {
+        self.data.children.iter()
+            .filter_map(|thing| thing.as_comment())
+            .collect()
+    }
+    
+    /// Extract posts as owned values
+    pub fn into_posts(self) -> Vec {
+        self.data.children.into_iter()
+            .filter_map(|thing| {
+                if thing.kind == "t3" {
+                    match thing.data {
+                        RedditThingData::Post(post) => Some(post),
+                        _ => None,
+                    }
+                } else {
+                    None
+                }
+            })
+            .collect()
+    }
+    
+    /// Extract comments as owned values
+    pub fn into_comments(self) -> Vec {
+        self.data.children.into_iter()
+            .filter_map(|thing| {
+                if thing.kind == "t1" {
+                    match thing.data {
+                        RedditThingData::Comment(comment) => Some(comment),
+                        _ => None,
+                    }
+                } else {
+                    None
+                }
+            })
+            .collect()
+    }
+}
+
+
+
diff --git a/legacy/reducer.rs b/legacy/reducer.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1ba8030feaf783b066b2e0bba4d2d05659c992f4
--- /dev/null
+++ b/legacy/reducer.rs
@@ -0,0 +1,845 @@
+use std::collections::{HashMap, HashSet, VecDeque};
+
+use serde::{Deserialize, Serialize};
+
+use crate::canonical_path::canonicalize_tag;
+use crate::dsl;
+use crate::events::{Event, Ingest, ThreadCapability};
+use crate::path_types::ItemId;
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub enum ScopeId {
+    Public,
+    Room(String),
+}
+
+/// Wire `room` field → content scope (`"public"` → [`ScopeId::Public`]).
+pub fn scope_from_room_wire(room: &str) -> ScopeId {
+    let r = room.trim();
+    if r.is_empty() || r == "public" {
+        ScopeId::Public
+    } else {
+        ScopeId::Room(r.to_string())
+    }
+}
+
+/// Parsed vote data (internal representation).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct VoteData {
+    pub ts: i64,
+    pub a: ItemId,
+    pub b: ItemId,
+    pub ratio_left: i32,
+    pub ratio_right: i32,
+    pub body: String,
+    pub principal: String,
+    pub delegate: Option,
+    /// Forum channel where this vote was cast (tag only, not room id).
+    pub thread_tag: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct GroupState {
+    pub item_to_idx: HashMap,
+    pub idx_to_item: Vec,
+
+    /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.
+    pub edges: HashMap<(usize, usize), f64>,
+
+    /// Unordered pairs that have at least one vote recorded between them (i,
+    pub dirty: bool,
+    pub cached_scores: Vec,
+    pub recent_votes: VecDeque,
+}
+
+impl Default for GroupState {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl GroupState {
+    pub fn new() -> Self {
+        Self {
+            item_to_idx: HashMap::new(),
+            idx_to_item: Vec::new(),
+            edges: HashMap::new(),
+            voted_pairs: HashSet::new(),
+            dirty: true,
+            cached_scores: Vec::new(),
+            recent_votes: VecDeque::with_capacity(200),
+        }
+    }
+
+    fn ensure_item(&mut self, item: &ItemId) -> usize {
+        if let Some(&idx) = self.item_to_idx.get(item) {
+            return idx;
+        }
+        let idx = self.idx_to_item.len();
+        self.idx_to_item.push(item.clone());
+        self.item_to_idx.insert(item.clone(), idx);
+        self.dirty = true;
+        idx
+    }
+
+    /// Public test helper: insert an item into the group without a vote (for unit tests).
+    pub fn ensure_item_pub(&mut self, item: &str) -> usize {
+        if let Some(canon) = ItemId::parse(item) {
+            self.ensure_item(&canon)
+        } else {
+            // Fallback: treat as raw storage string
+            let canon = ItemId::opaque(item.to_string());
+            self.ensure_item(&canon)
+        }
+    }
+
+    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {
+        if w <= 0.0 {
+            return;
+        }
+        *self.edges.entry((src, dst)).or_insert(0.0) += w;
+        self.dirty = true;
+    }
+
+    pub fn apply_vote(&mut self, mut vote: VoteData) {
+        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());
+        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());
+        vote.thread_tag = canonicalize_tag(&vote.thread_tag);
+        if vote.ratio_left < 0 {
+            vote.ratio_left = 0;
+        }
+        if vote.ratio_right < 0 {
+            vote.ratio_right = 0;
+        }
+        let a_idx = self.ensure_item(&vote.a);
+        let b_idx = self.ensure_item(&vote.b);
+
+        let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };
+        self.voted_pairs.insert((i, j));
+
+        let w_a = vote.ratio_left as f64;
+        let w_b = vote.ratio_right as f64;
+
+        self.add_edge_weight(b_idx, a_idx, w_a);
+        self.add_edge_weight(a_idx, b_idx, w_b);
+
+        self.recent_votes.push_front(vote);
+        while self.recent_votes.len() > 200 {
+            self.recent_votes.pop_back();
+        }
+    }
+}
+
+/// Compact rank-history entry stored per item in the ledger.
+/// `caused_by` is resolved lazily at query time from `ingests_by_id`.
+#[derive(Debug, Clone)]
+pub struct RankHistoryEntry {
+    pub ts: i64,
+    pub scope_rank: usize,
+    pub scope_rank_delta: i32,
+    pub scope_total: usize,
+    pub global_rank: usize,
+    pub global_rank_delta: i32,
+    pub global_total: usize,
+    pub score: f64,
+    pub thread: String,
+    pub post_id: String,
+}
+
+/// Invite link materialized from log events [`crate::events::InviteMinted`] /
+/// [`crate::events::InviteRedeemed`] when those are replayed.
+///
+/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);
+/// they are not appended to the JSONL log, so they disappear on restart. This struct is for
+/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.
+#[derive(Debug, Clone)]
+pub struct ActiveInviteState {
+    pub room_id: String,
+    pub capabilities: HashSet,
+    pub inviter: String,
+    pub uses_remaining: u32,
+    pub expires_ts_ms: Option,
+}
+
+#[derive(Clone, Debug)]
+pub enum RoomTimelineKind {
+    RoomCreated {
+        owner: String,
+        slug: String,
+    },
+    RoomDeleted {
+        deleted_by: String,
+    },
+    GrantAdded {
+        username: String,
+        granted_by: String,
+        capabilities: Vec,
+    },
+    GrantRevoked {
+        username: String,
+        revoked_by: String,
+        capabilities: Vec,
+    },
+}
+
+#[derive(Clone, Debug)]
+pub struct RoomTimelineEntry {
+    pub ts: i64,
+    pub kind: RoomTimelineKind,
+}
+
+#[derive(Debug, Clone)]
+#[derive(Default)]
+pub struct ForumThreadState {
+    pub last_activity_ts: i64,
+    /// Username of the most recent person who bumped this thread.
+    pub last_actor: String,
+}
+
+
+#[derive(Debug, Clone, Default)]
+pub struct ContentState {
+    pub ranking_group: GroupState,
+    pub items: HashSet,
+    pub item_bodies: HashMap,
+    /// Parent [`ItemId`] -> direct children.
+    pub item_children: HashMap>,
+    /// Per-item vote history (most recent first).
+    pub item_votes: HashMap>,
+    /// Per-item ingest references (most recent first).
+    pub item_snippets: HashMap>,
+    /// Item path -> threads that mention or vote on this item.
+    pub item_threads: HashMap>,
+    /// Per-item rank history, oldest first.
+    pub rank_history: HashMap>,
+}
+
+#[derive(Debug, Clone)]
+pub struct ReducerState {
+    pub content: HashMap,
+
+    /// (provider, provider_id) -> username
+    pub users_by_provider: HashMap<(String, String), String>,
+    /// token_id -> (username, salt, token_hash)
+    pub tokens_by_id: HashMap,
+    pub agent_bindings: HashMap,
+
+
+    pub ingests_by_id: HashMap,
+    /// (scope, thread_tag) → ingest ids, newest first.
+    pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,
+    /// Private room ids (`shortid/slug`) known from [`RoomCreated`].
+    pub rooms: HashSet,
+    /// (scope, thread_tag) → last activity.
+    pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,
+    pub actor_last_post_ts: HashMap,
+
+    /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.
+    pub ingests_ordered: Vec,
+    /// Username → ingest ids in post order (oldest first), for profile pages.
+    pub posts_by_actor: HashMap>,
+    /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).
+    pub redacted_posts: HashSet,
+    /// Redaction event timestamp (ms) per post id.
+    pub post_redact_ts: HashMap,
+    /// room_id → username → capabilities
+    pub grants: HashMap>>,
+    /// room_id → chronological room admin lines (for thread UI).
+    pub room_timeline: HashMap>,
+    /// Invite token → active invite (absent when fully consumed or never minted).
+    pub invites: HashMap,
+}
+
+impl ReducerState {
+    pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {
+        self.content.get(scope)
+    }
+
+    /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).
+    pub fn try_thread_post_index_chronological(
+        &self,
+        scope: &ScopeId,
+        thread_tag: &str,
+        post_id: &str,
+    ) -> Option {
+        let tag = canonicalize_tag(thread_tag);
+        self.ingests_by_scope_thread
+            .get(&(scope.clone(), tag))
+            .and_then(|q| q.iter().rev().position(|pid| pid == post_id))
+    }
+
+    pub fn thread_post_index_chronological(
+        &self,
+        scope: &ScopeId,
+        thread_tag: &str,
+        post_id: &str,
+    ) -> usize {
+        self.try_thread_post_index_chronological(scope, thread_tag, post_id)
+            .expect(
+                "post_id must appear in ingests_by_scope_thread for this scope and thread",
+            )
+    }
+
+    /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.
+    pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {
+        self.posts_by_actor
+            .get(actor)
+            .map(|q| q.iter().cloned().collect())
+            .unwrap_or_default()
+    }
+
+    /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.
+    pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {
+        self.posts_by_actor_ids(actor)
+            .into_iter()
+            .filter(|id| {
+                !self.redacted_posts.contains(id)
+                    && self.ingests_by_id.get(id).is_some_and(|ing| {
+                        let scope = scope_from_room_wire(&ing.room_id);
+                        match &scope {
+                            ScopeId::Public => true,
+                            ScopeId::Room(rid) => {
+                                viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))
+                            }
+                        }
+                    })
+            })
+            .collect()
+    }
+
+    pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {
+        self.grants
+            .get(room_id)
+            .and_then(|t| t.get(username))
+            .map(|caps| caps.contains(&cap))
+            .unwrap_or(false)
+    }
+
+    /// Invite link is present, not expired, and has uses left.
+    pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {
+        let inv = self.invites.get(token)?;
+        if inv.uses_remaining == 0 {
+            return None;
+        }
+        if let Some(exp) = inv.expires_ts_ms {
+            if now_ms > exp {
+                return None;
+            }
+        }
+        Some(inv)
+    }
+
+    pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {
+        self.content.entry(scope).or_default()
+    }
+
+    pub fn public(&self) -> &ContentState {
+        self.content.get(&ScopeId::Public).expect("public scope missing")
+    }
+
+    /// Register parent→child edges for the full ancestor chain.
+    /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `""→a`.
+    /// Stops early when an intermediate is already registered (its ancestors must be too).
+    /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4
+    fn add_child_edge(content: &mut ContentState, item: &ItemId) {
+        let mut child = item.clone();
+        loop {
+            let Some(parent) = child.parent() else { break };
+            let is_new = content
+                .item_children
+                .entry(parent.clone())
+                .or_default()
+                .insert(child);
+            if !is_new { break; }
+            child = parent;
+        }
+    }
+
+    /// Resolve an item path as a first-class [`ItemId`].
+    fn normalize_item(item: &str) -> Option {
+        ItemId::parse(item)
+    }
+
+    /// 1-indexed rank of `item` within its connected component in the parent scope.
+    /// 0 if the item has no votes connecting it to siblings (unranked).
+    fn scope_rank_of(
+        group: &GroupState,
+        item: &ItemId,
+        item_children: &HashMap>,
+    ) -> usize {
+        let scope = match item.parent() {
+            Some(p) => p,
+            None => return 0,
+        };
+        let children = match item_children.get(&scope) {
+            None => return 0,
+            Some(c) => c,
+        };
+        let &item_global_idx = match group.item_to_idx.get(item) {
+            None => return 0,
+            Some(i) => i,
+        };
+        // Map scope children to compact local indices.
+        let sibling_idxs: Vec = children.iter()
+            .filter_map(|c| group.item_to_idx.get(c).copied())
+            .collect();
+        let global_to_local: HashMap = sibling_idxs.iter()
+            .enumerate().map(|(l, &g)| (g, l)).collect();
+        let item_local = match global_to_local.get(&item_global_idx) {
+            None => return 0,
+            Some(&l) => l,
+        };
+        // Connected components within scope.
+        let (comps, _) = crate::ranking::connected_components_from_voted_pairs(
+            sibling_idxs.len(),
+            group.voted_pairs.iter().filter_map(|(a, b)| {
+                Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))
+            }),
+        );
+        // Find the component containing this item.
+        let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {
+            None => return 0,
+            Some(c) => c,
+        };
+        let comp_global: Vec = comp_local.iter()
+            .filter_map(|&l| sibling_idxs.get(l).copied())
+            .collect();
+        let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);
+        ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)
+    }
+
+    /// 1-indexed position of `item` in the component-aware global flat list.
+    /// Components sorted largest-first; items ranked within each component.
+    /// 0 if the item is not in the ranking group.
+    fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {
+        if !group.item_to_idx.contains_key(item) {
+            return 0;
+        }
+        let n = group.idx_to_item.len();
+        let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(
+            n, group.voted_pairs.iter().copied(),
+        );
+        comps.sort_by_key(|b| std::cmp::Reverse(b.len()));
+        let mut pos = 1usize;
+        for comp in &comps {
+            let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);
+            for r in &ranked {
+                if &r.item == item {
+                    return pos;
+                }
+                pos += 1;
+            }
+        }
+        0
+    }
+
+    /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).
+    fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {
+        let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;
+        let canonical_thread = canonicalize_tag(&ing.thread_tag);
+
+        let voted_items: Vec = doc
+            .statements
+            .iter()
+            .filter_map(|s| {
+                if let dsl::Stmt::Vote { item1, item2, .. } = s {
+                    Some([item1, item2])
+                } else {
+                    None
+                }
+            })
+            .flat_map(|pair| pair.into_iter())
+            .filter_map(|raw| Self::normalize_item(raw))
+            .collect::>()
+            .into_iter()
+            .collect();
+
+        let principal = ing.principal.clone();
+        let delegate = ing.delegate.clone();
+
+        let before: HashMap = if !voted_items.is_empty() {
+            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);
+            voted_items
+                .iter()
+                .map(|it| {
+                    (
+                        it.clone(),
+                        (
+                            Self::scope_rank_of(&content.ranking_group, it, &content.item_children),
+                            Self::global_rank_of(&content.ranking_group, it),
+                        ),
+                    )
+                })
+                .collect()
+        } else {
+            HashMap::new()
+        };
+
+        let mut ingest_items: HashSet = HashSet::new();
+
+        for stmt in doc.statements {
+            match stmt {
+                dsl::Stmt::Item { title, body } => {
+                    let Some(item) = Self::normalize_item(&title) else {
+                        continue;
+                    };
+                    nav!(content.items, set_elem(item.clone()));
+                    ingest_items.insert(item.clone());
+                    Self::add_child_edge(content, &item);
+
+                    if let Some(body_text) = body {
+                        if !body_text.trim().is_empty() {
+                            nav!(content.item_bodies, keypath(item.clone()), setval(body_text));
+                        }
+                    }
+                }
+                dsl::Stmt::Vote {
+                    item1,
+                    item2,
+                    ratio_left,
+                    ratio_right,
+                    explanation,
+                } => {
+                    let Some(item_a) = Self::normalize_item(&item1) else {
+                        continue;
+                    };
+                    let Some(item_b) = Self::normalize_item(&item2) else {
+                        continue;
+                    };
+
+                    let vote = VoteData {
+                        ts: ing.ts,
+                        a: item_a.clone(),
+                        b: item_b.clone(),
+                        ratio_left,
+                        ratio_right,
+                        body: explanation,
+                        principal: principal.clone(),
+                        delegate: delegate.clone(),
+                        thread_tag: canonical_thread.clone(),
+                    };
+
+                    ingest_items.insert(item_a.clone());
+                    ingest_items.insert(item_b.clone());
+
+                    nav!(content.items, set_elem(item_a.clone()));
+                    nav!(content.items, set_elem(item_b.clone()));
+                    Self::add_child_edge(content, &item_a);
+                    Self::add_child_edge(content, &item_b);
+
+                    content.ranking_group.apply_vote(vote.clone());
+
+                    for it in [&item_a, &item_b] {
+                        nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));
+                    }
+                }
+                dsl::Stmt::Prose { .. } => {}
+            }
+        }
+
+        for item in ingest_items.iter() {
+            nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));
+        }
+
+        for item in ingest_items.iter() {
+            nav!(
+                content.item_threads,
+                keypath(item.clone()),
+                set_elem(canonical_thread.clone())
+            );
+        }
+
+        if !voted_items.is_empty() {
+            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);
+            let thread = canonical_thread.clone();
+            for item in &voted_items {
+                let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);
+                let after_global = Self::global_rank_of(&content.ranking_group, item);
+                let score = content
+                    .ranking_group
+                    .item_to_idx
+                    .get(item)
+                    .and_then(|&i| content.ranking_group.cached_scores.get(i))
+                    .copied()
+                    .unwrap_or(0.0);
+                let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));
+                let prev = content.rank_history.get(item).and_then(|v| v.last());
+                let scope_delta = if prev.is_none() {
+                    0
+                } else {
+                    after_scope as i32 - before_scope as i32
+                };
+                let global_delta = if prev.is_none() {
+                    0
+                } else {
+                    after_global as i32 - before_global as i32
+                };
+                let scope_total = item
+                    .parent()
+                    .and_then(|p| content.item_children.get(&p))
+                    .map(|s| s.len())
+                    .unwrap_or(0);
+                let global_total = content.ranking_group.idx_to_item.len();
+                content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {
+                    ts: ing.ts,
+                    scope_rank: after_scope,
+                    scope_rank_delta: scope_delta,
+                    scope_total,
+                    global_rank: after_global,
+                    global_rank_delta: global_delta,
+                    global_total,
+                    score,
+                    thread: thread.clone(),
+                    post_id: ing.id.clone(),
+                });
+            }
+        }
+
+        Ok(())
+    }
+
+    fn rebuild_scope_content(&mut self, scope: ScopeId) {
+        let mut cs = ContentState::default();
+        for id in &self.ingests_ordered {
+            if self.redacted_posts.contains(id) {
+                continue;
+            }
+            let Some(ing) = self.ingests_by_id.get(id) else {
+                continue;
+            };
+            if scope_from_room_wire(&ing.room_id) != scope {
+                continue;
+            }
+            let _ = Self::apply_ingest_to_content(&mut cs, ing);
+        }
+        self.content.insert(scope, cs);
+        // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so
+        // per-post URLs and chronological indices remain stable; only projected garden state resets.
+    }
+
+    /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).
+    fn purge_private_room(&mut self, room_id: &str) {
+        let scope = ScopeId::Room(room_id.to_string());
+        self.rooms.remove(room_id);
+        self.grants.remove(room_id);
+        self.room_timeline.remove(room_id);
+        self.invites.retain(|_, inv| inv.room_id != room_id);
+        self.content.remove(&scope);
+        self.forum_threads.retain(|(s, _), _| s != &scope);
+        self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);
+
+        let mut to_drop: Vec = self
+            .ingests_by_id
+            .iter()
+            .filter(|(_, ing)| ing.room_id.trim() == room_id)
+            .map(|(id, _)| id.clone())
+            .collect();
+        to_drop.sort();
+        to_drop.dedup();
+        for id in to_drop {
+            if let Some(ing) = self.ingests_by_id.remove(&id) {
+                self.posts_by_actor
+                    .entry(ing.principal)
+                    .and_modify(|q| {
+                        q.retain(|x| x != &id);
+                    });
+            }
+            self.ingests_ordered.retain(|x| x != &id);
+            self.redacted_posts.remove(&id);
+            self.post_redact_ts.remove(&id);
+        }
+    }
+
+    pub fn apply_event(&mut self, event: Event) {
+        match event {
+            Event::UserRegistered(ur) => {
+                self.users_by_provider.insert(
+                    (ur.provider.to_lowercase(), ur.provider_id.clone()),
+                    ur.username,
+                );
+            }
+            Event::TokenIssued(ti) => {
+                self.tokens_by_id.insert(
+                    ti.token_id.clone(),
+                    (ti.username, ti.salt.clone(), ti.token_hash.clone()),
+                );
+            }
+            Event::AgentBound(ab) => {
+                if ab.agent.is_empty() {
+                    return;
+                }
+                self.agent_bindings.insert(ab.agent, ab.username);
+            }
+            Event::RoomCreated(rc) => {
+                self.rooms.insert(rc.room_id.clone());
+                self.room_timeline
+                    .entry(rc.room_id.clone())
+                    .or_default()
+                    .push(RoomTimelineEntry {
+                        ts: rc.ts,
+                        kind: RoomTimelineKind::RoomCreated {
+                            owner: rc.owner.clone(),
+                            slug: rc.slug.clone(),
+                        },
+                    });
+            }
+            Event::RoomDeleted(rd) => {
+                let room_id = rd.room_id.clone();
+                if self.rooms.contains(&room_id) {
+                    self.room_timeline
+                        .entry(room_id.clone())
+                        .or_default()
+                        .push(RoomTimelineEntry {
+                            ts: rd.ts,
+                            kind: RoomTimelineKind::RoomDeleted {
+                                deleted_by: rd.deleted_by.clone(),
+                            },
+                        });
+                }
+                self.purge_private_room(&room_id);
+            }
+            Event::Ingest(mut ing) => {
+                ing.thread_tag = canonicalize_tag(&ing.thread_tag);
+                let room_key = ing.room_id.trim().to_string();
+                let scope = scope_from_room_wire(&room_key);
+                let canonical_thread = ing.thread_tag.clone();
+                let scope_thread_key = (scope.clone(), canonical_thread.clone());
+
+                {
+                    let content = self.content_for_scope_mut(scope.clone());
+                    if Self::apply_ingest_to_content(content, &ing).is_err() {
+                        eprintln!(
+                            "WARNING: Skipping malformed ingest event {}: parse failed",
+                            ing.id
+                        );
+                        return;
+                    }
+                }
+
+                self.ingests_by_id.insert(ing.id.clone(), ing.clone());
+
+                let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();
+                let prev_ts = ft.last_activity_ts;
+                if ing.ts > prev_ts {
+                    ft.last_activity_ts = ing.ts;
+                    ft.last_actor = ing.principal.clone();
+                }
+
+                nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));
+
+                nav!(self.ingests_ordered, push_back(ing.id.clone()));
+
+                nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));
+
+                nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));
+            }
+            Event::PostRedacted(pr) => {
+                self.redacted_posts.insert(pr.post_id.clone());
+                self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);
+                let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {
+                    return;
+                };
+                let scope = scope_from_room_wire(ing.room_id.trim());
+                // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.
+                self.rebuild_scope_content(scope);
+            }
+            Event::GrantAdded(ga) => {
+                let room_id = ga.room_id.clone();
+                let caps = self.grants
+                    .entry(ga.room_id)
+                    .or_default()
+                    .entry(ga.username.clone())
+                    .or_default();
+                for cap in ga.capabilities.iter().copied() {
+                    caps.insert(cap);
+                }
+                self.room_timeline
+                    .entry(room_id)
+                    .or_default()
+                    .push(RoomTimelineEntry {
+                        ts: ga.ts,
+                        kind: RoomTimelineKind::GrantAdded {
+                            username: ga.username.clone(),
+                            granted_by: ga.granted_by.clone(),
+                            capabilities: ga.capabilities.clone(),
+                        },
+                    });
+            }
+            Event::GrantRevoked(gr) => {
+                let room_id = gr.room_id.clone();
+                if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {
+                    let username = gr.username.clone();
+                    if let Some(caps) = room_grants.get_mut(&username) {
+                        for cap in &gr.capabilities {
+                            caps.remove(cap);
+                        }
+                        if caps.is_empty() {
+                            room_grants.remove(&username);
+                        }
+                    }
+                    if room_grants.is_empty() {
+                        self.grants.remove(&gr.room_id);
+                    }
+                }
+                self.room_timeline
+                    .entry(room_id)
+                    .or_default()
+                    .push(RoomTimelineEntry {
+                        ts: gr.ts,
+                        kind: RoomTimelineKind::GrantRevoked {
+                            username: gr.username.clone(),
+                            revoked_by: gr.revoked_by.clone(),
+                            capabilities: gr.capabilities.clone(),
+                        },
+                    });
+            }
+            Event::InviteMinted(im) => {
+                self.invites.insert(
+                    im.token.clone(),
+                    ActiveInviteState {
+                        room_id: im.room_id.clone(),
+                        capabilities: im.capabilities.iter().copied().collect(),
+                        inviter: im.inviter.clone(),
+                        uses_remaining: im.max_uses,
+                        expires_ts_ms: im.expires_ts_ms,
+                    },
+                );
+            }
+            Event::InviteRedeemed(ir) => {
+                if let Some(inv) = self.invites.get_mut(&ir.token) {
+                    inv.uses_remaining = inv.uses_remaining.saturating_sub(1);
+                    if inv.uses_remaining == 0 {
+                        self.invites.remove(&ir.token);
+                    }
+                }
+            }
+        }
+    }
+}
+
+impl Default for ReducerState {
+    fn default() -> Self {
+        let mut content = HashMap::new();
+        content.insert(ScopeId::Public, ContentState::default());
+        Self {
+            content,
+            users_by_provider: HashMap::new(),
+            tokens_by_id: HashMap::new(),
+            agent_bindings: HashMap::new(),
+            ingests_by_id: HashMap::new(),
+            ingests_by_scope_thread: HashMap::new(),
+            rooms: HashSet::new(),
+            forum_threads: HashMap::new(),
+            actor_last_post_ts: HashMap::new(),
+            ingests_ordered: Vec::new(),
+            posts_by_actor: HashMap::new(),
+            redacted_posts: HashSet::new(),
+            post_redact_ts: HashMap::new(),
+            grants: HashMap::new(),
+            room_timeline: HashMap::new(),
+            invites: HashMap::new(),
+        }
+    }
+}
+
diff --git a/legacy/ui_action.rs b/legacy/ui_action.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5e4131dd346a6f80bfe091a9b0cf7902721bc47f
--- /dev/null
+++ b/legacy/ui_action.rs
@@ -0,0 +1,270 @@
+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).
+//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).
+
+use crate::form_template::fill_template_from_form;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use thiserror::Error;
+
+/// Form field name for the compact JSON template (possibly with `{"$form":"…"}` holes).
+pub const UI_RPC_FIELD: &str = "__rpc__";
+
+fn default_ui_form_action() -> String {
+    "/ui".to_string()
+}
+
+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(tag = "action", rename_all = "snake_case")]
+pub enum HtmlUiAction {
+    /// Forum ingest via `POST /ui`.
+    PostIngest {
+        room: String,
+        thread_tag: String,
+        text: String,
+        #[serde(default)]
+        error_target: Option,
+        #[serde(default)]
+        form_id: Option,
+    },
+    /// DSL check / validation via `POST /ui`.
+    CheckIngest {
+        room: String,
+        thread_tag: String,
+        text: String,
+        #[serde(default)]
+        error_target: Option,
+        #[serde(default)]
+        form_id: Option,
+    },
+    /// Post a pairwise vote from `/vote` (browser compose).
+    VoteComparePost {
+        room: String,
+        thread_tag: String,
+        left_item: String,
+        right_item: String,
+        /// From form fields (string); parsed server-side.
+        ratio_left: String,
+        ratio_right: String,
+        explanation: String,
+        /// Same-origin path after successful post (JS redirect).
+        next: String,
+        /// Pool parent item path, if the vote was initiated from a pool URL.
+        #[serde(default)]
+        pool: Option,
+        #[serde(default = "default_ui_form_action")]
+        form_action: String,
+    },
+    /// 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`.
+    SetGardenPin {
+        #[serde(default)]
+        clear: bool,
+        #[serde(default)]
+        room_wire: String,
+        #[serde(default)]
+        item_storage: Option,
+        next: String,
+        /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.
+        #[serde(default = "default_ui_form_action")]
+        form_action: String,
+    },
+    /// Resolve children or siblings for an external garden item.
+    ResolveExternal {
+        room_wire: String,
+        item_storage: String,
+        mode: String,
+        next: String,
+        #[serde(default = "default_ui_form_action")]
+        form_action: String,
+    },
+    /// Author redacts own post via `POST /ui`.
+    RedactPost { post_id: String },
+    /// Morph `#room-members-section` — members list open or collapsed (server-rendered).
+    SetRoomMembersExpanded {
+        room_wire: String,
+        #[serde(default)]
+        expanded: bool,
+    },
+    /// Delete the private room (Manage only); redirects to `/` on success.
+    DeleteRoom { room: String },
+    /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home).
+    SetNewThreadComposeExpanded {
+        room_wire: String,
+        #[serde(default)]
+        expanded: bool,
+    },
+    /// Replace a truncated post card with the full body (same thread index).
+    ExpandPostFull {
+        room: String,
+        thread_tag: String,
+        post_index: usize,
+    },
+    /// Expand a redacted/tombstone post to show stripped body (author view).
+    ExpandRedactedPost {
+        room: String,
+        thread_tag: String,
+        post_index: usize,
+    },
+    /// Collapse an expanded redacted post back to the tombstone card.
+    CollapseRedactedPost {
+        room: String,
+        thread_tag: String,
+        post_index: usize,
+    },
+    /// Copy full thread text (CLI `forum show` format) to the clipboard.
+    CopyThread {
+        room: String,
+        thread_tag: String,
+        copy_btn_id: String,
+    },
+}
+
+#[derive(Debug, Error)]
+pub enum HtmlUiParseError {
+    #[error("missing __rpc__ field")]
+    MissingRpc,
+    #[error("invalid template json: {0}")]
+    Template(serde_json::Error),
+    #[error("invalid ui action: {0}")]
+    Action(serde_json::Error),
+}
+
+/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.
+pub fn parse_html_ui_from_form(
+    form: &HashMap,
+) -> Result {
+    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;
+    let mut hole_map = form.clone();
+    hole_map.remove(UI_RPC_FIELD);
+    let v: Value =
+        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;
+    serde_json::from_value(v).map_err(HtmlUiParseError::Action)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn round_trip_post_ingest_with_holes() {
+        let template = serde_json::json!({
+            "action": "post_ingest",
+            "room": "public",
+            "thread_tag": {"$form": "thread_tag"},
+            "text": {"$form": "text"},
+            "error_target": "e",
+            "form_id": "f",
+        });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        form.insert("thread_tag".into(), "x".into());
+        form.insert("text".into(), "body".into());
+
+        let a = parse_html_ui_from_form(&form).unwrap();
+        assert_eq!(
+            a,
+            HtmlUiAction::PostIngest {
+                room: "public".into(),
+                thread_tag: "x".into(),
+                text: "body".into(),
+                error_target: Some("e".into()),
+                form_id: Some("f".into()),
+            }
+        );
+    }
+
+    #[test]
+    fn expand_post_full_round_trip() {
+        let template = serde_json::json!({
+            "action": "expand_post_full",
+            "room": "public",
+            "thread_tag": "demo",
+            "post_index": 3,
+        });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        let a = parse_html_ui_from_form(&form).unwrap();
+        assert_eq!(
+            a,
+            HtmlUiAction::ExpandPostFull {
+                room: "public".into(),
+                thread_tag: "demo".into(),
+                post_index: 3,
+            }
+        );
+    }
+
+    #[test]
+    fn set_room_members_expanded_defaults_false() {
+        let template = serde_json::json!({
+            "action": "set_room_members_expanded",
+            "room_wire": "ab/cd",
+        });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        let a = parse_html_ui_from_form(&form).unwrap();
+        assert_eq!(
+            a,
+            HtmlUiAction::SetRoomMembersExpanded {
+                room_wire: "ab/cd".into(),
+                expanded: false,
+            }
+        );
+    }
+
+    #[test]
+    fn copy_thread_round_trip() {
+        let template = serde_json::json!({
+            "action": "copy_thread",
+            "room": "public",
+            "thread_tag": "demo",
+            "copy_btn_id": "thread-copy-top",
+        });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        let a = parse_html_ui_from_form(&form).unwrap();
+        assert_eq!(
+            a,
+            HtmlUiAction::CopyThread {
+                room: "public".into(),
+                thread_tag: "demo".into(),
+                copy_btn_id: "thread-copy-top".into(),
+            }
+        );
+    }
+
+    #[test]
+    fn set_new_thread_compose_expanded_true() {
+        let template = serde_json::json!({
+            "action": "set_new_thread_compose_expanded",
+            "room_wire": "ab/cd",
+            "expanded": true,
+        });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        let a = parse_html_ui_from_form(&form).unwrap();
+        assert_eq!(
+            a,
+            HtmlUiAction::SetNewThreadComposeExpanded {
+                room_wire: "ab/cd".into(),
+                expanded: true,
+            }
+        );
+    }
+}
diff --git a/legacy/views.rs b/legacy/views.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d4f0ffc49475f014698b4da0de6f476884430813
--- /dev/null
+++ b/legacy/views.rs
@@ -0,0 +1,63 @@
+use std::{
+    collections::HashMap,
+    sync::{Arc, Mutex},
+};
+use tokio::sync::mpsc;
+
+type CountMap = Arc>>;
+
+#[derive(Clone)]
+pub struct ViewStore {
+    counts: CountMap,
+    flush_tx: mpsc::Sender<()>,
+}
+
+impl ViewStore {
+    pub fn new(json_path: &str) -> Self {
+        // Load existing counts from disk on startup (best-effort)
+        let initial: HashMap = std::fs::read_to_string(json_path)
+            .ok()
+            .and_then(|s| serde_json::from_str(&s).ok())
+            .unwrap_or_default();
+
+        let counts: CountMap = Arc::new(Mutex::new(initial));
+        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
+        let path = json_path.to_string();
+
+        let counts_for_writer = counts.clone();
+        tokio::spawn(async move {
+            while flush_rx.recv().await.is_some() {
+                while flush_rx.try_recv().is_ok() {}
+
+                let snapshot: HashMap = {
+                    counts_for_writer.lock().unwrap().clone()
+                };
+
+                let path = path.clone();
+                let _ = tokio::task::spawn_blocking(move || {
+                    if let Ok(json) = serde_json::to_string(&snapshot) {
+                        let tmp = format!("{path}.tmp");
+                        if std::fs::write(&tmp, &json).is_ok() {
+                            let _ = std::fs::rename(&tmp, &path);
+                        }
+                    }
+                })
+                .await;
+            }
+        });
+
+        Self { counts, flush_tx }
+    }
+
+    pub fn increment(&self, path: String) {
+        {
+            let mut map = self.counts.lock().unwrap();
+            *map.entry(path).or_insert(0) += 1;
+        }
+        let _ = self.flush_tx.try_send(());
+    }
+
+    pub fn get_views(&self, path: &str) -> u64 {
+        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
+    }
+}
diff --git a/legacy/vote.rs b/legacy/vote.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d0ec78cd675eae284d056fb3b8eaf5cc853d6263
--- /dev/null
+++ b/legacy/vote.rs
@@ -0,0 +1,580 @@
+use axum::{
+    extract::{Path, Query, State},
+    http::{HeaderMap, StatusCode, Uri},
+    response::{Html, IntoResponse},
+};
+use axum_extra::extract::cookie::CookieJar;
+use maud::html;
+use serde::Deserialize;
+use serde_json::json;
+use std::collections::{HashMap, HashSet};
+
+use crate::{
+    api::optional_principal,
+    canonical_path::canonicalize_tag,
+    form_template::template_json_compact,
+    html::{
+        forum::ThreadNav,
+        layout_full_bleed_chromeless,
+        ratio_pct, render_item_body_in_scope,
+        theme_from_jar, theme_next_from_uri,
+        user_can_post_room,
+        ui_action::UI_RPC_FIELD,
+        JsBuilder,
+    },
+    middleware::canonical_view_url,
+    path_types::ItemId,
+    reducer::{ContentState, ScopeId},
+    scope_rank::suggest_next_pair_in_pool,
+    state::AppState,
+};
+
+use super::{
+    access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},
+    item::{item_display_path, login_href_with_next},
+};
+
+fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {
+    let cands: HashSet = content
+        .item_threads
+        .get(a)
+        .into_iter()
+        .chain(content.item_threads.get(b))
+        .flat_map(|s| s.iter().cloned())
+        .collect();
+    if cands.is_empty() {
+        return "vote".to_string();
+    }
+    let mut v: Vec = cands.into_iter().collect();
+    v.sort();
+    canonicalize_tag(&v[0])
+}
+
+/// Canonical unordered pair: lexicographic by storage string (stable edge identity).
+pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {
+    let ac = a.clone().normalized_storage();
+    let bc = b.clone().normalized_storage();
+    if ac.as_str() <= bc.as_str() {
+        (ac, bc)
+    } else {
+        (bc, ac)
+    }
+}
+
+/// All votes whose endpoints are exactly this unordered pair (unsorted).
+pub(super) fn edge_vote_entries_for_pair(
+    content: &ContentState,
+    a: &ItemId,
+    b: &ItemId,
+) -> Vec {
+    let (lo, hi) = canonical_edge_items(a, b);
+    let lo_s = lo.as_str();
+    let hi_s = hi.as_str();
+    content
+        .item_votes
+        .get(&lo)
+        .into_iter()
+        .flat_map(|q| q.iter())
+        .filter(|v| {
+            (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
+                || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
+        })
+        .cloned()
+        .collect()
+}
+
+pub(super) fn ratios_for_compare_page(
+    v: &crate::reducer::VoteData,
+    page_left: &ItemId,
+    page_right: &ItemId,
+) -> (i32, i32) {
+    let pl = page_left.as_str();
+    let pr = page_right.as_str();
+    match (v.a.as_str(), v.b.as_str()) {
+        (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right),
+        (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left),
+        _ => (v.ratio_left, v.ratio_right),
+    }
+}
+
+fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {
+    let l = ratio_left.max(0) as f64;
+    let r = ratio_right.max(0) as f64;
+    let sum = l + r;
+    if sum <= 0.0 {
+        0.5
+    } else {
+        l / sum
+    }
+}
+
+/// Stronger preference for **`page_left` first**; ties **newer first**.
+pub(super) fn sort_votes_for_compare_display(
+    mut votes: Vec,
+    page_left: &ItemId,
+    page_right: &ItemId,
+) -> Vec {
+    votes.sort_by(|va, vb| {
+        let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right);
+        let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right);
+        let sa = left_share_normalized(ratio_left_a, ratio_right_a);
+        let sb = left_share_normalized(ratio_left_b, ratio_right_b);
+        match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {
+            std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts),
+            o => o,
+        }
+    });
+    votes
+}
+
+/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).
+pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {
+    let (lo, hi) = canonical_edge_items(a, b);
+    let lo_s = lo.as_str();
+    let hi_s = hi.as_str();
+    content
+        .item_votes
+        .get(&lo)
+        .into_iter()
+        .flat_map(|q| q.iter())
+        .filter(|v| {
+            (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
+                || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
+        })
+        .count()
+}
+
+fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {
+    let set: HashSet = content
+        .item_threads
+        .get(a)
+        .into_iter()
+        .chain(content.item_threads.get(b))
+        .flat_map(|s| s.iter().cloned())
+        .collect();
+    let mut v: Vec = set.into_iter().collect();
+    v.sort();
+    v.into_iter().map(|t| canonicalize_tag(&t)).collect()
+}
+
+fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {
+    let votes = edge_vote_entries_for_pair(content, left, right);
+    let votes = sort_votes_for_compare_display(votes, left, right);
+    let legend_left = item_display_path(left.as_str());
+    let legend_right = item_display_path(right.as_str());
+    html! {
+        @if votes.is_empty() {
+            p class="muted vote-edge-empty" { "no votes on this pair in this scope yet" }
+        } @else {
+            h3 class="vote-edge-history-title" {
+                "votes on this edge"
+                span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) }
+            }
+            ul class="vote-edge-history" {
+                @for v in &votes {
+                    @let (r_left, r_right) = ratios_for_compare_page(v, left, right);
+                    @let pct = ratio_pct(r_left, r_right);
+                    @let row_tip = format!(
+                        "{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}",
+                        r_left,
+                        r_right,
+                        legend_left,
+                        legend_right,
+                        v.thread_tag,
+                        v.principal,
+                    );
+                    li class="vote-edge-history-row" title=(row_tip) {
+                        div class="vote-edge-meta" {
+                            span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
+                            span class="muted" { " · #" (v.thread_tag) " · @" (v.principal) }
+                        }
+                        div class="ratio-bar vote-edge-bar" aria-hidden="true" {
+                            div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
+                            div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
+                        }
+                        @if !v.body.trim().is_empty() {
+                            div class="vote-edge-reason muted" { (v.body.trim()) }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// After a successful vote post: refresh edge history (no in-page preview card).
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn vote_compare_post_success_js(
+    state: &AppState,
+    nav: &ThreadNav,
+    _room_wire: &str,
+    _thread_tag: &str,
+    left: &ItemId,
+    right: &ItemId,
+    pool: Option<&ItemId>,
+    _post_id: &str,
+    _post_idx: Option,
+) -> String {
+    let reduced = state.reduced.read().await;
+    let content = content_for_garden_view(&reduced, &nav.scope());
+    let edge_history = vote_edge_history_markup(content, left, right);
+    let next_pair = suggest_next_vote_pair(content, left, right, pool);
+    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);
+    drop(reduced);
+    JsBuilder::new()
+        .morph_inner_selector("#vote-edge-history-region", edge_history)
+        .morph_selector(".vote-compare-nav", nav_markup)
+        .build()
+}
+
+pub(super) fn vote_compare_href(
+    nav: &ThreadNav,
+    left: &ItemId,
+    right: &ItemId,
+    thread_override: Option<&str>,
+    pool: Option<&ItemId>,
+) -> String {
+    let left_dp = left.display_path();
+    let right_dp = right.display_path();
+    let left_q = urlencoding::encode(&left_dp);
+    let right_q = urlencoding::encode(&right_dp);
+    let mut base = format!(
+        "{}/vote?left={}&right={}",
+        nav.room_path_prefix_for_vote_compare(),
+        left_q,
+        right_q
+    );
+    if let Some(t) = thread_override.filter(|s| !s.is_empty()) {
+        base = format!("{}&thread={}", base, urlencoding::encode(t));
+    }
+    if let Some(p) = pool {
+        let pool_dp = p.display_path();
+        base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp));
+    }
+    base
+}
+
+pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
+    let display = ItemId::parse(pool_item_str)
+        .map(|i| i.display_path())
+        .unwrap_or_else(|| pool_item_str.to_string());
+    format!(
+        "{}/vote?pool={}",
+        nav.room_path_prefix_for_vote_compare(),
+        urlencoding::encode(&display)
+    )
+}
+
+fn vote_compare_nav_markup(
+    nav: &ThreadNav,
+    next_pair: Option<&(ItemId, ItemId)>,
+    pool: Option<&ItemId>,
+) -> maud::Markup {
+    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));
+    html! {
+        div class="vote-compare-nav" {
+            @if let Some(href) = &next_pair_href {
+                a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" }
+            } @else {
+                span class="vote-compare-next is-disabled" { "no next pair" }
+            }
+        }
+    }
+}
+
+pub(super) fn suggest_next_vote_pair(
+    content: &ContentState,
+    current_left: &ItemId,
+    current_right: &ItemId,
+    pool_parent: Option<&ItemId>,
+) -> Option<(ItemId, ItemId)> {
+    let pool: Vec = if let Some(parent) = pool_parent {
+        content
+            .item_children
+            .get(parent)
+            .map(|s| s.iter().cloned().collect())
+            .unwrap_or_default()
+    } else if current_left.parent().as_ref().map(|p| p.as_str())
+        == current_right.parent().as_ref().map(|p| p.as_str())
+    {
+        current_left
+            .parent()
+            .and_then(|parent| {
+                content
+                    .item_children
+                    .get(&parent.normalized_storage())
+                    .cloned()
+            })
+            .map(|children| children.into_iter().collect())
+            .unwrap_or_default()
+    } else {
+        Vec::new()
+    };
+    if pool.len() < 2 {
+        return None;
+    }
+    suggest_next_pair_in_pool(
+        &content.ranking_group,
+        &pool,
+        Some((current_left, current_right)),
+    )
+}
+
+pub(super) fn vote_compare_item_card(
+    nav: &ThreadNav,
+    item: &ItemId,
+    body: Option<&String>,
+    side_class: &str,
+    item_bodies: Option<&HashMap>,
+) -> maud::Markup {
+    html! {
+        div class=(format!("vote-compare-side {side_class}")) {
+            a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) {
+                code { (item_display_path(item.as_str())) }
+            }
+            @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {
+                div class="vote-compare-item-body" {
+                    (render_item_body_in_scope(
+                        body,
+                        nav.garden_root_url(),
+                        item_bodies,
+                    ))
+                }
+            } @else {
+                p class="muted vote-compare-item-body-empty" { "no body yet" }
+            }
+        }
+    }
+}
+#[derive(Debug, Deserialize)]
+pub struct VoteCompareQuery {
+    #[serde(default)]
+    pub left: Option,
+    #[serde(default)]
+    pub right: Option,
+    #[serde(default)]
+    pub thread: Option,
+    #[serde(default)]
+    pub pool: Option,
+}
+
+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.
+pub async fn vote_compare_page(
+    State(state): State,
+    Query(q): Query,
+    headers: HeaderMap,
+    jar: CookieJar,
+    uri: Uri,
+) -> impl IntoResponse {
+    let nav = ThreadNav::public();
+    vote_compare_inner(state, q, nav, headers, jar, uri).await
+}
+
+pub async fn room_vote_compare_page(
+    State(state): State,
+    Path(room_key): Path,
+    Query(q): Query,
+    headers: HeaderMap,
+    jar: CookieJar,
+    uri: Uri,
+) -> impl IntoResponse {
+    let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+        return (StatusCode::NOT_FOUND, "bad room path").into_response();
+    };
+    let Some(nav) = ThreadNav::from_room_id(&room_id) else {
+        return (StatusCode::NOT_FOUND, "bad room path").into_response();
+    };
+    let reduced = state.reduced.read().await;
+    let user = optional_principal(&headers, &jar, &reduced);
+    if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
+        drop(reduced);
+        return room_not_found_page(&jar, &uri).into_response();
+    }
+    if !room_scope_has_garden_content(&reduced, &nav) {
+        drop(reduced);
+        return room_not_found_page(&jar, &uri).into_response();
+    }
+    drop(reduced);
+    vote_compare_inner(state, q, nav, headers, jar, uri).await
+}
+
+async fn vote_compare_inner(
+    state: AppState,
+    q: VoteCompareQuery,
+    nav: ThreadNav,
+    headers: HeaderMap,
+    jar: CookieJar,
+    uri: Uri,
+) -> axum::response::Response {
+    let pool_id: Option = match q.pool.as_deref() {
+        Some(p) => match ItemId::parse(p.trim()) {
+            Some(i) => Some(i.normalized_storage()),
+            None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(),
+        },
+        None => None,
+    };
+
+    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {
+        (Some(l), Some(r)) => {
+            let left = match ItemId::parse(l.trim()) {
+                Some(i) => i.normalized_storage(),
+                None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
+            };
+            let right = match ItemId::parse(r.trim()) {
+                Some(i) => i.normalized_storage(),
+                None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
+            };
+            if left == right {
+                return (StatusCode::BAD_REQUEST, "items must differ").into_response();
+            }
+            (left, right)
+        }
+        (None, None) => {
+            let Some(pool) = pool_id.as_ref() else {
+                return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response();
+            };
+            let reduced = state.reduced.read().await;
+            let content = content_for_garden_view(&reduced, &nav.scope());
+            let children: Vec = content
+                .item_children
+                .get(pool)
+                .map(|s| s.iter().cloned().collect())
+                .unwrap_or_default();
+            if children.len() < 2 {
+                drop(reduced);
+                return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response();
+            }
+            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);
+            drop(reduced);
+            match pair {
+                Some(p) => p,
+                None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(),
+            }
+        }
+        _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(),
+    };
+
+    let reduced = state.reduced.read().await;
+    let content = content_for_garden_view(&reduced, &nav.scope());
+    let viewer = optional_principal(&headers, &jar, &reduced);
+    let can_post = match &nav.scope() {
+        ScopeId::Public => viewer.is_some(),
+        ScopeId::Room(rid) => viewer
+            .as_ref()
+            .map(|u| user_can_post_room(&reduced, rid, u))
+            .unwrap_or(false),
+    };
+    let auto_thread = q
+        .thread
+        .as_ref()
+        .map(|t| canonicalize_tag(t))
+        .filter(|t| !t.is_empty())
+        .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));
+    let thread_tags = vote_thread_tags_for_pair(content, &left, &right);
+    let edge_history = vote_edge_history_markup(content, &left, &right);
+    let left_body = content.item_bodies.get(&left).cloned();
+    let right_body = content.item_bodies.get(&right).cloned();
+    let item_bodies_for_cards = content.item_bodies.clone();
+    let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());
+    drop(reduced);
+
+    let title = format!(
+        "vote — {} vs {}",
+        item_display_path(left.as_str()),
+        item_display_path(right.as_str())
+    );
+    let next_path = uri
+        .path_and_query()
+        .map(|pq| pq.as_str().to_string())
+        .unwrap_or_else(|| "/vote".into());
+
+    let rpc_json = template_json_compact(&json!({
+        "action": "vote_compare_post",
+        "room": nav.room_wire,
+        "thread_tag": {"$form": "thread_tag"},
+        "left_item": left.as_str(),
+        "right_item": right.as_str(),
+        "ratio_left": {"$form": "ratio_left"},
+        "ratio_right": {"$form": "ratio_right"},
+        "explanation": {"$form": "explanation"},
+        "next": next_path,
+        "pool": pool_id.as_ref().map(|p| p.as_str()),
+        "form_action": "/ui",
+    }))
+    .expect("vote compare rpc json");
+
+    let body = html! {
+    section class="vote-compare-shell" {
+        h2 { "compare" }
+        div class="vote-compare-pair" {
+            (vote_compare_item_card(
+                &nav,
+                &left,
+                left_body.as_ref(),
+                "vote-compare-left",
+                Some(&item_bodies_for_cards),
+            ))
+            span class="vote-compare-vs" { "vs" }
+            (vote_compare_item_card(
+                &nav,
+                &right,
+                right_body.as_ref(),
+                "vote-compare-right",
+                Some(&item_bodies_for_cards),
+            ))
+        }
+        (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))
+        div id="vote-edge-history-region" {
+            (edge_history)
+        }
+        @if can_post {
+            form id="vote-compare-form" method="POST" action="/ui" {
+                input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
+                div class="vote-thread-picker" {
+                    label class="vote-thread-picker-label" { "thread" }
+                    select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" {
+                        @if thread_tags.is_empty() {
+                            option value="vote" selected { "#vote" }
+                        }
+                        @for t in &thread_tags {
+                            @if *t == auto_thread {
+                                option value=(t) selected { "#" (t) }
+                            } @else {
+                                option value=(t) { "#" (t) }
+                            }
+                        }
+                    }
+                }
+                input type="hidden" name="ratio_left" id="vote-ratio-left" value="50";
+                input type="hidden" name="ratio_right" id="vote-ratio-right" value="50";
+                label class="vote-compare-slider-label" {
+                    span id="vote-slider-left-label" { (item_display_path(left.as_str())) }
+                    input type="range" id="vote-preference-slider" min="0" max="100" value="50"
+                        aria-valuemin="0" aria-valuemax="100";
+                    span id="vote-slider-right-label" { (item_display_path(right.as_str())) }
+                }
+                label class="vote-explain-label" { "reason (required)" }
+                textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {}
+                div id="vote-compare-errors" {}
+                p { button type="submit" { "post vote" } }
+            }
+        } @else {
+            p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." }
+        }
+    }
+    };
+
+    let url_key = canonical_view_url(&uri);
+    let view_count = state.views.get_views(&url_key);
+
+    let page = layout_full_bleed_chromeless(
+        &title,
+        "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen",
+        body,
+        Some(view_count),
+        theme_from_jar(&jar),
+        &theme_next_from_uri(&uri),
+    );
+    Html(page.into_string()).into_response()
+}
+
diff --git a/mod.rs b/mod.rs
deleted file mode 100644
index b1dfe895515a1ba94304c52ac4ea82ff6bb84c16..0000000000000000000000000000000000000000
--- a/mod.rs
+++ /dev/null
@@ -1,929 +0,0 @@
-use axum::{
-    body::Body,
-    extract::Path,
-    http::{header, HeaderValue, StatusCode, Uri},
-    response::{IntoResponse, Response},
-    Form,
-};
-use axum_extra::extract::cookie::CookieJar;
-use maud::{html, Markup, DOCTYPE};
-use serde::Deserialize;
-use std::collections::{HashMap, HashSet};
-
-mod auth;
-mod breadcrumb_path;
-mod editor;
-mod forum;
-mod garden;
-pub mod routing;
-mod search;
-pub mod ui_action;
-use breadcrumb_path::{ExternalOntologyPath, OntologyPath};
-
-pub use auth::{
-    auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
-    choose_username_page,
-};
-pub use editor::{editor_check, editor_page};
-pub use forum::{
-    home, room_page, room_thread_post_view, room_thread_view, thread_feed_html,
-    thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav,
-};
-
-pub use forum::user_profile_page;
-pub(crate) use forum::{
-    fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,
-    thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post,
-    user_can_post_room, user_can_view_room,
-};
-pub(crate) use garden::{
-    encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,
-    GARDEN_PIN_COOKIE,
-};
-pub use garden::{
-    external_garden_index, external_ontology_path, garden_index, ontology_path,
-    room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,
-    room_vote_compare_page, vote_compare_page,
-};
-pub use routing::RouteContext;
-pub use search::{search_page, search_results_fragment};
-pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};
-
-/// Public profile URL path for a stored username (no `@`).
-pub(crate) fn profile_href(username: &str) -> String {
-    format!("/u/{username}")
-}
-
-/// Cookie name for UI theme (must match document.cookie migration in [`layout`]).
-pub const SLUG_THEME_COOKIE: &str = "slug-theme";
-
-/// Normalize a requested theme id to a known stylesheet key.
-pub fn normalize_theme(raw: &str) -> &'static str {
-    match raw {
-        "retro" => "retro",
-        "retro_craft" => "retro_craft",
-        _ => "default",
-    }
-}
-
-/// Resolved theme for rendering and cookie re-issue.
-pub fn theme_from_jar(jar: &CookieJar) -> &'static str {
-    jar.get(SLUG_THEME_COOKIE)
-        .map(|c| normalize_theme(c.value()))
-        .unwrap_or("default")
-}
-
-/// `Path` + optional `?query` for round-tripping after `POST /theme`.
-pub fn theme_next_from_uri(uri: &Uri) -> String {
-    uri.path_and_query()
-        .map(|pq| pq.as_str().to_string())
-        .filter(|s| !s.is_empty())
-        .unwrap_or_else(|| "/".to_string())
-}
-
-/// `Set-Cookie` for a validated theme (ASCII cookie value).
-pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {
-    let t = normalize_theme(theme);
-    let s = format!("{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000");
-    HeaderValue::from_str(&s).expect("theme cookie must be ASCII")
-}
-
-/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme.
-pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option {
-    let c = jar.get(SLUG_THEME_COOKIE)?;
-    Some(theme_cookie_header_value(c.value()))
-}
-
-fn sanitize_theme_next(next: Option<&str>) -> String {
-    let s = next.unwrap_or("/").trim();
-    if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 {
-        s.to_string()
-    } else {
-        "/".to_string()
-    }
-}
-
-#[derive(Debug, Deserialize)]
-pub struct ThemeForm {
-    theme: String,
-    next: Option,
-}
-
-/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch).
-pub async fn post_theme(Form(form): Form) -> impl IntoResponse {
-    let theme = normalize_theme(&form.theme);
-    let next = sanitize_theme_next(form.next.as_deref());
-    let loc =
-        HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static("/"));
-    Response::builder()
-        .status(StatusCode::SEE_OTHER)
-        .header(header::LOCATION, loc)
-        .header(header::SET_COOKIE, theme_cookie_header_value(theme))
-        .body(Body::empty())
-        .expect("theme redirect response")
-}
-
-// Embed CSS and shared UI script at compile time
-const THEME_DEFAULT_CSS: &str = include_str!("../../static/theme_default.css");
-const THEME_RETRO_CSS: &str = include_str!("../../static/theme_retro.css");
-const THEME_RETRO_CRAFT_CSS: &str = include_str!("../../static/theme_retro_craft.css");
-const SLUG_UI_JS: &str = include_str!("../../static/slug_ui.js");
-
-pub async fn serve_static(Path(filename): Path) -> impl IntoResponse {
-    if filename == "slug_ui.js" {
-        return Response::builder()
-            .status(StatusCode::OK)
-            .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
-            .header(header::CACHE_CONTROL, "public, max-age=3600")
-            .body(SLUG_UI_JS.to_string())
-            .unwrap()
-            .into_response();
-    }
-
-    let theme = filename
-        .strip_prefix("theme_")
-        .and_then(|s| s.strip_suffix(".css"));
-
-    let css = match theme {
-        Some("default") => THEME_DEFAULT_CSS,
-        Some("retro") => THEME_RETRO_CSS,
-        Some("retro_craft") => THEME_RETRO_CRAFT_CSS,
-        _ => return (StatusCode::NOT_FOUND, "static file not found").into_response(),
-    };
-
-    Response::builder()
-        .status(StatusCode::OK)
-        .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
-        .header(header::CACHE_CONTROL, "public, max-age=3600")
-        .body(css.to_string())
-        .unwrap()
-        .into_response()
-}
-
-pub(crate) fn js_string_literal(s: &str) -> String {
-    serde_json::to_string(s).expect("javascript string escaping")
-}
-
-/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures.
-pub(crate) fn ui_js_warn(msg: &str) -> Response {
-    let js = format!("console.warn({});", js_string_literal(msg));
-    Response::builder()
-        .status(StatusCode::OK)
-        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
-        .body(Body::from(js))
-        .unwrap()
-}
-
-pub(crate) struct JsBuilder {
-    snippets: Vec,
-}
-
-pub(crate) struct JsQueryBuilder {
-    builder: JsBuilder,
-    expr: String,
-}
-
-impl JsBuilder {
-    pub(crate) fn new() -> Self {
-        Self {
-            snippets: Vec::new(),
-        }
-    }
-
-    pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self {
-        self.morph_expr(
-            &format!("document.querySelector({})", js_string_literal(selector)),
-            markup,
-            None,
-        )
-    }
-
-    /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`.
-    pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self {
-        self.qs(selector).morph_inner(markup)
-    }
-
-    pub(crate) fn morph_expr(
-        mut self,
-        expr: &str,
-        markup: Markup,
-        morph_style: Option<&str>,
-    ) -> Self {
-        let html = js_string_literal(&markup.into_string());
-        let opts = morph_style
-            .map(|style| format!(", {{morphStyle: {}}}", js_string_literal(style)))
-            .unwrap_or_default();
-        self.snippets.push(format!(
-            "var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}",
-        ));
-        self
-    }
-
-    pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder {
-        JsQueryBuilder {
-            builder: self,
-            expr: format!("document.querySelector({})", js_string_literal(selector)),
-        }
-    }
-
-    pub(crate) fn id(self, id: &str) -> JsQueryBuilder {
-        self.qs(&format!("#{id}"))
-    }
-
-    pub(crate) fn if_current_path_matches(
-        mut self,
-        path: &str,
-        f: impl FnOnce(JsBuilder) -> JsBuilder,
-    ) -> Self {
-        let inner = f(JsBuilder::new()).build();
-        self.snippets.push(format!(
-            "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}",
-            path = js_string_literal(path),
-        ));
-        self
-    }
-
-    pub(crate) fn if_current_path_not_matches(
-        mut self,
-        path: &str,
-        f: impl FnOnce(JsBuilder) -> JsBuilder,
-    ) -> Self {
-        let inner = f(JsBuilder::new()).build();
-        self.snippets.push(format!(
-            "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}",
-            path = js_string_literal(path),
-        ));
-        self
-    }
-
-    pub(crate) fn redirect(mut self, to: &str) -> Self {
-        self.snippets
-            .push(format!("window.location = {};", js_string_literal(to)));
-        self
-    }
-
-    pub(crate) fn clipboard_write_text_and_label_btn(
-        mut self,
-        text: &str,
-        btn_id: &str,
-        copied_label: &str,
-    ) -> Self {
-        self.snippets.push(format!(
-            "navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});",
-            text = js_string_literal(text),
-            btn_id = js_string_literal(btn_id),
-            label = js_string_literal(copied_label),
-        ));
-        self
-    }
-
-    /// Focus first matching element (e.g. after morphing open a compose form).
-    pub(crate) fn focus_selector(mut self, selector: &str) -> Self {
-        self.snippets.push(format!(
-            "var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}",
-            js_string_literal(selector),
-        ));
-        self
-    }
-
-    pub(crate) fn build(self) -> String {
-        self.snippets.join(" ")
-    }
-
-    pub(crate) fn into_response(self) -> Response {
-        Response::builder()
-            .status(StatusCode::OK)
-            .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
-            .body(Body::from(self.build()))
-            .unwrap()
-    }
-}
-
-impl JsQueryBuilder {
-    pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder {
-        let html = js_string_literal(&markup.into_string());
-        self.builder.snippets.push(format!(
-            "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}",
-            expr = self.expr,
-        ));
-        self.builder
-    }
-
-    pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder {
-        let html = js_string_literal(&markup.into_string());
-        self.builder.snippets.push(format!(
-            "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}",
-            expr = self.expr,
-        ));
-        self.builder
-    }
-
-    pub(crate) fn reset(mut self) -> JsBuilder {
-        self.builder.snippets.push(format!(
-            "var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}",
-            expr = self.expr,
-        ));
-        self.builder
-    }
-}
-
-#[allow(clippy::too_many_arguments)]
-pub(super) fn layout(
-    title: &str,
-    view: &str,
-    body: Markup,
-    views: Option,
-    theme: &str,
-    theme_next: &str,
-    garden_room_wire: Option<&str>,
-    garden_path_prefix: Option<&str>,
-) -> Markup {
-    layout_embed_controls(
-        title,
-        view,
-        body,
-        views,
-        theme,
-        theme_next,
-        garden_room_wire,
-        garden_path_prefix,
-        true,
-    )
-}
-
-/// Minimal document shell: no bottom controls, no garden HUD data attributes (`data-garden-room` /
-/// `data-garden-prefix` empty). For routes that own the full viewport (e.g. vote compare).
-pub(super) fn layout_full_bleed_chromeless(
-    title: &str,
-    view: &str,
-    body: Markup,
-    views: Option,
-    theme: &str,
-    theme_next: &str,
-) -> Markup {
-    layout_embed_controls(
-        title, view, body, views, theme, theme_next, None, None, false,
-    )
-}
-
-#[allow(clippy::too_many_arguments)]
-fn layout_embed_controls(
-    title: &str,
-    view: &str,
-    body: Markup,
-    views: Option,
-    theme: &str,
-    theme_next: &str,
-    garden_room_wire: Option<&str>,
-    garden_path_prefix: Option<&str>,
-    show_controls: bool,
-) -> Markup {
-    let theme = normalize_theme(theme);
-    let css_href = format!("/static/theme_{theme}.css");
-    html! {
-        (DOCTYPE)
-        html {
-            head {
-                meta charset="utf-8";
-                meta name="viewport" content="width=device-width, initial-scale=1";
-                title { (title) }
-                link rel="stylesheet" href=(css_href) id="theme-stylesheet";
-                script src="https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js" {}
-            }
-            body class=(view)
-                data-garden-room=(garden_room_wire.unwrap_or(""))
-                data-garden-prefix=(garden_path_prefix.unwrap_or("")) {
-                @if let Some(n) = views {
-                    span class="view-meta muted" { (n) " views" }
-                }
-                div id="errors" {}
-                (body)
-                @if show_controls {
-                div id="controls" {
-                    a href="https://github.com/sortersocial/slug" id="src-link" { "src" }
-                    div id="spread-control" {
-                        span { "spread" }
-                        input type="range" id="spread-slider" min="0" max="1" step="0.05" value="1";
-                    }
-                    @if let (Some(gr), Some(ref gpx)) = (garden_room_wire, garden_path_prefix) {
-                        @if !gr.is_empty() {
-                            div id="slug-pin-hud" class="slug-pin-hud" data-garden-prefix=(gpx) {}
-                        }
-                    }
-                    a id="search-btn" href="/search" { "search" }
-                    form id="slug-theme-form" method="post" action="/theme" {
-                        input type="hidden" name="next" value=(theme_next);
-                        select id="theme-select" name="theme" onchange="this.form.submit()" aria-label="Theme" {
-                            @for (val, label) in [("default", "default"), ("retro", "retro"), ("retro_craft", "craft")] {
-                                @if theme == val {
-                                    option value=(val) selected { (label) }
-                                } @else {
-                                    option value=(val) { (label) }
-                                }
-                            }
-                        }
-                    }
-                }
-                }
-                script src="/static/slug_ui.js" {}
-            }
-        }
-    }
-}
-
-pub(super) fn now_ms() -> i64 {
-    let t = std::time::SystemTime::now()
-        .duration_since(std::time::UNIX_EPOCH)
-        .unwrap_or_default();
-    t.as_millis() as i64
-}
-
-pub(super) fn ratio_pct(left: i32, right: i32) -> f64 {
-    let l = (left.max(0)) as f64;
-    let r = (right.max(0)) as f64;
-    let denom = l + r;
-    if denom <= 0.0 {
-        return 50.0;
-    }
-    (l / denom) * 100.0
-}
-
-/// Render a single breadcrumb segment with `/` separator.
-pub(super) fn bc_segment(label: &str, href: &str, is_current: bool) -> Markup {
-    html! {
-        span class="bc-sep" { " / " }
-        @if is_current {
-            a href=(href) class="bc-current" { (label) }
-        } @else {
-            a href=(href) { (label) }
-        }
-    }
-}
-
-/// Breadcrumb for external ontology `/-/https://…`
-fn bc_path_external(path: &ExternalOntologyPath) -> Markup {
-    html! {
-        a href="/" { "slug.social" }
-        (bc_segment("-", "/-", path.is_root()))
-        @for (i, id) in path.breadcrumb_chain().iter().enumerate() {
-            @let disp = id.display_path();
-            @let tail = disp.strip_prefix("-/").unwrap_or(disp.as_str());
-            @let href = format!("/-/{}", tail);
-            @let is_last = i + 1 == path.breadcrumb_chain().len();
-            (bc_segment(id.last_segment(), &href, is_last))
-        }
-    }
-}
-
-/// Breadcrumb for any ontology path segment, e.g. "parables" or "parables/counting-the-cost".
-fn bc_path(path: &OntologyPath) -> Markup {
-    html! {
-        a href=(path.slug_root_href()) { "slug.social" }
-        (bc_segment("~", "/~", path.is_root()))
-        @for (i, seg) in path.segments().iter().enumerate() {
-            @let href = format!("/~/{}", path.segments()[..=i].join("/"));
-            @let is_last = i == path.segments().len() - 1;
-            (bc_segment(seg, &href, is_last))
-        }
-    }
-}
-
-/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post.
-/// Root link toggles to `/~` only at thread-root (`/`).
-pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup {
-    let root_href = if thread_tag.is_some() { "/" } else { "/~" };
-    let root_is_current = thread_tag.is_none();
-    html! {
-        @if root_is_current {
-            a href=(root_href) class="bc-current" { "slug.social" }
-        } @else {
-            a href=(root_href) { "slug.social" }
-        }
-        @if let Some(tag) = thread_tag {
-            @if let Some(idx) = focused_post {
-                (bc_segment(&format!("#{tag}"), &format!("/t/{tag}"), false))
-                (bc_segment(&format!("post #{idx}"), &format!("/t/{tag}/{idx}"), true))
-            } @else {
-                (bc_segment(&format!("#{tag}"), &format!("/t/{tag}"), true))
-            }
-        }
-    }
-}
-
-/// Short display label for a stored agent id (`uuid:rig:model`, no `@`).
-pub(super) fn actor_label(agent_naked: &str) -> String {
-    let a = agent_naked.trim();
-    let parts: Vec<&str> = a.split(':').collect();
-    if parts.len() >= 3 {
-        let rig = parts[1].trim();
-        let model = parts[2].trim();
-        // X actors: show @handle instead of uuid hash.
-        if rig == "x.com" {
-            return format!("@{model}");
-        }
-        let uuid = parts[0].trim();
-        let uuid8 = uuid.chars().take(8).collect::();
-        if !uuid8.is_empty() && !rig.is_empty() && !model.is_empty() {
-            return format!("{uuid8}:{rig}:{model}");
-        }
-    }
-    a.to_string()
-}
-
-/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present.
-pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String {
-    match delegate {
-        Some(d) => format!("@@{}", actor_label(d)),
-        None => format!("@{principal}"),
-    }
-}
-
-/// Escape HTML special chars for safe injection.
-fn escape_html(s: &str) -> String {
-    let mut out = String::with_capacity(s.len());
-    for c in s.chars() {
-        match c {
-            '&' => out.push_str("&"),
-            '<' => out.push_str("<"),
-            '>' => out.push_str(">"),
-            '"' => out.push_str("""),
-            _ => out.push(c),
-        }
-    }
-    out
-}
-
-/// Max characters from an item body placed in a `title` tooltip (native hover).
-const ITEM_LINK_TITLE_MAX_CHARS: usize = 500;
-
-fn collapse_whitespace_for_title(s: &str) -> String {
-    let mut out = String::with_capacity(s.len().min(ITEM_LINK_TITLE_MAX_CHARS + 8));
-    let mut last_was_space = true;
-    for c in s.chars() {
-        if c.is_whitespace() {
-            if !last_was_space {
-                out.push(' ');
-                last_was_space = true;
-            }
-        } else {
-            out.push(c);
-            last_was_space = false;
-        }
-    }
-    out.trim().to_string()
-}
-
-fn item_body_title_snippet(body: &str) -> Option {
-    let s = collapse_whitespace_for_title(body);
-    if s.is_empty() {
-        return None;
-    }
-    let truncated: String = s.chars().take(ITEM_LINK_TITLE_MAX_CHARS).collect();
-    let ellipsis = if s.chars().count() > ITEM_LINK_TITLE_MAX_CHARS {
-        "…"
-    } else {
-        ""
-    };
-    Some(format!("{truncated}{ellipsis}"))
-}
-
-fn garden_href_for_item_ref(
-    raw_ref: &str,
-    garden_prefix: &str,
-) -> Option<(crate::path_types::ItemId, String)> {
-    let key = slug_types::canonicalize_item(raw_ref);
-    let id = crate::path_types::ItemId::parse(&key)?;
-    let href = if let Some(tail) = id.tilde_tail() {
-        if tail.is_empty() {
-            garden_prefix.trim_end_matches('/').to_string()
-        } else {
-            format!("{}/{}", garden_prefix.trim_end_matches('/'), tail)
-        }
-    } else if id.as_str().starts_with("https://") || id.as_str().starts_with("http://") {
-        let display = id.display_path();
-        let rest = display.strip_prefix("-/").unwrap_or(display.as_str());
-        let ext_prefix = format!("{}-", garden_prefix.trim_end_matches('~'));
-        format!("{ext_prefix}/{rest}")
-    } else {
-        return None;
-    };
-    Some((id, href))
-}
-
-fn push_item_ref_anchor(
-    out: &mut String,
-    raw_ref: &str,
-    garden_prefix: &str,
-    item_bodies: Option<&HashMap>,
-) -> bool {
-    let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else {
-        return false;
-    };
-    out.push_str(r#"');
-    out.push_str(&escape_html(raw_ref));
-    out.push_str("");
-    true
-}
-
-/// Replace item refs in raw prose with clickable garden links.
-///
-/// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated
-/// body preview for native browser tooltips (forum posts, item pages).
-pub(super) fn linkify_slugs_with_prefix(
-    raw: &str,
-    garden_prefix: &str,
-    item_bodies: Option<&HashMap>,
-) -> String {
-    let mut out = String::with_capacity(raw.len() + 64);
-    for token in crate::dsl::tokenize_prose_item_refs(raw) {
-        match token {
-            crate::dsl::ProseToken::Text(text) => out.push_str(&escape_html(&text)),
-            crate::dsl::ProseToken::ItemRef(raw_ref) => {
-                if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) {
-                    out.push_str(&escape_html(&raw_ref));
-                }
-            }
-        }
-    }
-    out
-}
-
-#[derive(Clone)]
-struct EmbedFrame {
-    src: String,
-    title: String,
-    provider_class: &'static str,
-}
-
-fn clean_media_id(s: &str) -> String {
-    s.chars()
-        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
-        .collect()
-}
-
-fn query_param(url: &str, name: &str) -> Option {
-    let q = url.split_once('?')?.1;
-    for pair in q.split('&') {
-        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
-        if k == name {
-            return Some(v.to_string());
-        }
-    }
-    None
-}
-
-fn spotify_embed_src(url: &str) -> Option {
-    let rest = url
-        .strip_prefix("https://")
-        .or_else(|| url.strip_prefix("http://"))?;
-    let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
-    let host = host.to_lowercase();
-    if !(host == "open.spotify.com" || host == "www.open.spotify.com") {
-        return None;
-    }
-    let path = tail
-        .split('#')
-        .next()
-        .unwrap_or(tail)
-        .split('?')
-        .next()
-        .unwrap_or(tail);
-    let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
-    if segs.first().is_some_and(|s| s.starts_with("intl-")) {
-        segs.remove(0);
-    }
-    if segs.len() < 2 {
-        return None;
-    }
-    let kind = segs[0];
-    let id = clean_media_id(segs[1]);
-    if id.is_empty() {
-        return None;
-    }
-    let allowed = matches!(kind, "track" | "album" | "playlist" | "episode" | "show");
-    if !allowed {
-        return None;
-    }
-    Some(EmbedFrame {
-        src: format!("https://open.spotify.com/embed/{kind}/{id}"),
-        title: format!("Spotify {kind}"),
-        provider_class: "embed-spotify",
-    })
-}
-
-fn youtube_embed_src(url: &str) -> Option {
-    let rest = url
-        .strip_prefix("https://")
-        .or_else(|| url.strip_prefix("http://"))?;
-    let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
-    let host = host.to_lowercase();
-
-    let video_id = if host == "youtu.be" || host == "www.youtu.be" {
-        clean_media_id(
-            tail.split(['?', '#'])
-                .next()
-                .unwrap_or(tail)
-                .trim_matches('/'),
-        )
-    } else if matches!(
-        host.as_str(),
-        "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com"
-    ) {
-        let path = format!("/{}", tail.split('#').next().unwrap_or(tail));
-        if path.starts_with("/watch") {
-            clean_media_id(&query_param(url, "v")?)
-        } else if let Some(id) = path.strip_prefix("/shorts/") {
-            clean_media_id(id.split(['?', '/']).next().unwrap_or(id))
-        } else if let Some(id) = path.strip_prefix("/embed/") {
-            clean_media_id(id.split(['?', '/']).next().unwrap_or(id))
-        } else {
-            String::new()
-        }
-    } else {
-        String::new()
-    };
-
-    if video_id.is_empty() {
-        return None;
-    }
-    Some(EmbedFrame {
-        src: format!("https://www.youtube.com/embed/{video_id}"),
-        title: "YouTube video".to_string(),
-        provider_class: "embed-youtube",
-    })
-}
-
-fn extract_embed_frames(raw: &str) -> Vec {
-    let mut out = Vec::new();
-    let mut seen = HashSet::new();
-    for token in raw.split_whitespace() {
-        let token = token
-            .trim_matches(|c: char| c == '(' || c == '[' || c == '{' || c == '"' || c == '\'')
-            .trim_end_matches(|c: char| ".,;:!?)]}\"'".contains(c));
-        if !(token.starts_with("https://") || token.starts_with("http://")) {
-            continue;
-        }
-        let embed = spotify_embed_src(token).or_else(|| youtube_embed_src(token));
-        if let Some(frame) = embed {
-            if seen.insert(frame.src.clone()) {
-                out.push(frame);
-            }
-        }
-    }
-    out
-}
-
-pub(super) fn render_linkified_with_embeds_in_scope(
-    raw: &str,
-    garden_prefix: &str,
-    item_bodies: Option<&HashMap>,
-) -> Markup {
-    let embeds = extract_embed_frames(raw);
-    html! {
-        pre { (maud::PreEscaped(linkify_slugs_with_prefix(raw, garden_prefix, item_bodies))) }
-        @if !embeds.is_empty() {
-            div class="rich-embeds" {
-                @for e in embeds {
-                    div class=(format!("rich-embed {}", e.provider_class)) {
-                        iframe
-                            src=(e.src)
-                            title=(e.title)
-                            loading="lazy"
-                            allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
-                            referrerpolicy="strict-origin-when-cross-origin"
-                            allowfullscreen {}
-                    }
-                }
-            }
-        }
-    }
-}
-
-/// Item page / thread body: resolver-specific rich HTML, else linkified `
` + media embeds.
-pub(super) fn render_item_body_in_scope(
-    raw: &str,
-    garden_prefix: &str,
-    item_bodies: Option<&HashMap>,
-) -> Markup {
-    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {
-        return html! {
-            div class="item-body-rich" { (m) }
-        };
-    }
-    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)
-}
-
-/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
-fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
-    assert!(
-        !s.contains('\\') && !s.contains('\'') && !s.contains('\n') && !s.contains('\r'),
-        "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})"
-    );
-}
-
-/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.
-pub(super) fn cli_panel>(cmds: &[I]) -> Markup {
-    if cmds.is_empty() {
-        return html! {};
-    }
-    for cmd in cmds {
-        assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());
-    }
-    html! {
-        div class="cli-panel" {
-            span class="cli-panel-label muted" { "cli" }
-            div class="cli-panel-cmds" {
-                @for cmd in cmds {
-                    @let s = cmd.as_ref();
-                    button type="button" class="cli-panel-row" title="Copy command" onclick=(format!(
-                        r#"navigator.clipboard.writeText('{}');"#,
-                        s
-                    )) {
-                        code class="cli-panel-cmd" { (s) }
-                    }
-                }
-            }
-        }
-    }
-}
-
-/// Age bucket for recency coloring of thread entries.
-pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
-    let age_ms = now_ms.saturating_sub(ts_ms);
-    let age_secs = age_ms / 1000;
-    if age_secs < 3600 {
-        "age-fresh" // < 1 hour
-    } else if age_secs < 86400 {
-        "age-recent" // < 1 day
-    } else if age_secs < 86400 * 7 {
-        "age-week" // < 1 week
-    } else {
-        "age-old" // >= 1 week
-    }
-}
-
-#[cfg(test)]
-mod linkify_title_tests {
-    use super::*;
-    use crate::path_types::ItemId;
-    use std::collections::HashMap;
-
-    #[test]
-    fn tilde_link_gets_title_from_item_bodies() {
-        let mut bodies = HashMap::new();
-        let key = ItemId::parse(&slug_types::canonicalize_item("~/foo/bar")).unwrap();
-        bodies.insert(key, "Hello  world\nline".to_string());
-        let html = linkify_slugs_with_prefix("see ~/foo/bar ok", "/r/x/~", Some(&bodies));
-        assert!(html.contains("title=\"Hello world line\""));
-        assert!(html.contains("href=\"/r/x/~/foo/bar\""));
-    }
-
-    #[test]
-    fn raw_url_links_to_public_external_garden_page() {
-        let html = linkify_slugs_with_prefix("see https://example.com/z.", "/~", None);
-        assert!(html.contains(
-            r#"https://example.com/z."#
-        ));
-    }
-
-    #[test]
-    fn dash_ref_links_to_room_external_garden_page_with_title() {
-        let mut bodies = HashMap::new();
-        let key = ItemId::parse(&slug_types::canonicalize_item("-/example.com/z")).unwrap();
-        bodies.insert(key, "External body\npreview".to_string());
-        let html =
-            linkify_slugs_with_prefix("see -/example.com/z", "/r/9ab12cdroom/~", Some(&bodies));
-        assert!(html.contains(r#"href="/r/9ab12cdroom/-/https://example.com/z""#));
-        assert!(html.contains(r#"title="External body preview""#));
-    }
-
-    #[test]
-    fn code_fence_urls_are_not_linkified() {
-        let html = linkify_slugs_with_prefix(
-            "```json\n{\"url\":\"https://example.com/z\"}\n```\nthen https://example.com/a",
-            "/~",
-            None,
-        );
-        assert!(!html.contains(r#"href="/-/https://example.com/z""#));
-        assert!(html.contains(r#"href="/-/https://example.com/a""#));
-    }
-
-    #[test]
-    fn no_title_when_body_missing_or_empty() {
-        let html = linkify_slugs_with_prefix("x ~/a/b y", "/~", Some(&HashMap::new()));
-        assert!(!html.contains(" title="));
-    }
-}
diff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl
deleted file mode 100644
index 0f2231b94936c17610adf653ca26b0faa6f2ac3a..0000000000000000000000000000000000000000
--- a/pagerank_streaming.tdsl
+++ /dev/null
@@ -1,11 +0,0 @@
-eventually i want to have ranking histories.
-like "visionary" and "fraud" waxing and waning in a uplot graph over time for #elon-musk. 
-there are too many query combinations to precomupte the ranking histories 
-(arbitrary user filters, and tag overlap combinations maybe), 
-so we're just going to have to calculate them all on demand. 
-computers are fast, its okay. for each vote, we need to calculate rank centrality again. 
-i was thinking, for n votes we calculate the rank centrality, 
-and get node weights. we then output that data to the client (over websocket, or testable barrier). 
-then we calculate n+1 votes, _but we keep the node weights in memory_ 
-so the rank centrality process converges faster. 
-this also has the side effect of making the ranking stream in satisfyingly as you load the page.
diff --git a/parser.rs b/parser.rs
deleted file mode 100644
index 2b87b974f8d1dd93bee35681d87668a94e4ef349..0000000000000000000000000000000000000000
--- a/parser.rs
+++ /dev/null
@@ -1,1808 +0,0 @@
-use std::collections::HashMap;
-use std::rc::Rc;
-use std::cell::RefCell;
-use crate::ui::action::UIAction;
-use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
-    /// Matches exact literal string
-    Literal(&'static str),
-    
-    /// Matches any prefix of a string and suggests the full string
-    /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
-    PrefixOf(&'static str),
-    
-    /// Captures a variable segment (e.g., subreddit name, username)
-    Variable(&'static str),
-    
-    /// Matches any string (wildcard)
-    Any,
-}
-
-impl EdgePattern {
-    /// Try to match this pattern against input, return (consumed_chars, captured_value)
-    fn matches(&self, input: &str) -> Option<(usize, Option)> {
-        match self {
-            EdgePattern::Literal(lit) => {
-                if input.starts_with(lit) {
-                    Some((lit.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::PrefixOf(target) => {
-                // Check if input is a prefix of target
-                if target.starts_with(input) && !input.is_empty() {
-                    // It's a valid prefix
-                    Some((input.len(), None))
-                } else if input.starts_with(target) {
-                    // Full match
-                    Some((target.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Variable(var_name) => {
-                // Consume until next '/' or end of string
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    let captured = input[..end].to_string();
-                    // Validate based on variable type
-                    if is_valid_variable(var_name, &captured) {
-                        Some((end, Some(captured)))
-                    } else {
-                        None
-                    }
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Any => {
-                // Match everything until next '/' or end
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    Some((end, Some(input[..end].to_string())))
-                } else {
-                    None
-                }
-            }
-        }
-    }
-    
-    /// Get the completion suggestion for this pattern
-    fn completion(&self, partial: &str) -> Option {
-        match self {
-            EdgePattern::PrefixOf(target) => {
-                if target.starts_with(partial) && partial != *target {
-                    Some(target.to_string())
-                } else {
-                    None
-                }
-            }
-            _ => None,
-        }
-    }
-}
-
-/// Edge in the graph
-pub struct Edge {
-    pattern: EdgePattern,
-    target: NodeId,
-    /// Optional description for autocomplete
-    description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions
-type Handler = Box) -> UIAction>;
-
-/// Node in the graph
-pub struct Node {
-    #[allow(dead_code)]
-    id: NodeId,
-    edges: Vec,
-    handler: Option,
-}
-
-/// The composable parser graph
-pub struct Graph {
-    nodes: HashMap>>,
-    root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
-    nodes: HashMap>>,
-    current_node: Option,
-    root: NodeId,
-}
-
-impl GraphBuilder {
-    pub fn new() -> Self {
-        let mut nodes = HashMap::new();
-        let root_node = Rc::new(RefCell::new(Node {
-            id: "root",
-            edges: Vec::new(),
-            handler: None,
-        }));
-        nodes.insert("root", root_node);
-        
-        GraphBuilder {
-            nodes,
-            current_node: Some("root"),
-            root: "root",
-        }
-    }
-    
-    /// Select a node to add edges to
-    pub fn at(mut self, node_id: NodeId) -> Self {
-        // Create node if it doesn't exist
-        if !self.nodes.contains_key(node_id) {
-            let node = Rc::new(RefCell::new(Node {
-                id: node_id,
-                edges: Vec::new(),
-                handler: None,
-            }));
-            self.nodes.insert(node_id, node);
-        }
-        self.current_node = Some(node_id);
-        self
-    }
-    
-    /// Add an edge from the current node
-    pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
-        self.edge_with_desc(pattern, target, None)
-    }
-    
-    /// Add an edge with description
-    pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {
-        let current = self.current_node.expect("No current node selected");
-        
-        // Create target node if it doesn't exist
-        if !self.nodes.contains_key(target) {
-            let node = Rc::new(RefCell::new(Node {
-                id: target,
-                edges: Vec::new(),
-                handler: None,
-            }));
-            self.nodes.insert(target, node);
-        }
-        
-        // Add edge to current node
-        if let Some(node) = self.nodes.get(current) {
-            node.borrow_mut().edges.push(Edge {
-                pattern,
-                target,
-                description: desc,
-            });
-        }
-        
-        self
-    }
-    
-    /// Set handler for current node
-    pub fn handler(self, handler: F) -> Self 
-    where 
-        F: Fn(&str, &str, &HashMap) -> UIAction + 'static
-    {
-        let current = self.current_node.expect("No current node selected");
-        if let Some(node) = self.nodes.get(current) {
-            node.borrow_mut().handler = Some(Box::new(handler));
-        }
-        self
-    }
-    
-    /// Build the final graph
-    pub fn build(self) -> Graph {
-        Graph {
-            nodes: self.nodes,
-            root: self.root,
-        }
-    }
-}
-
-// --- Parser Implementation ---
-
-impl Graph {
-    pub fn parse(&self, input: &str) -> UIAction {
-        let normalized = input.trim().to_lowercase();
-        let mut state = ParserState {
-            input: &normalized,
-            cursor: 0,
-            current_node_id: self.root,
-            context: HashMap::new(),
-            original_query: input.to_string(),
-            current_prefix: String::new(),
-        };
-        
-        self.parse_recursive(&mut state)
-    }
-    
-    fn parse_recursive(&self, state: &mut ParserState) -> UIAction {
-        let node = self.nodes.get(state.current_node_id)
-            .expect("Node not found in graph");
-        let node_ref = node.borrow();
-        
-        // If we've consumed all input, check for handler or suggestions
-        if state.cursor >= state.input.len() {
-            if let Some(handler) = &node_ref.handler {
-                return handler(&state.original_query, &state.current_prefix, &state.context);
-            }
-            
-            // No handler, try to suggest based on available edges
-            return self.suggest_from_edges(&node_ref, state);
-        }
-        
-        let remaining = &state.input[state.cursor..];
-        
-        // Try to match each edge
-        for edge in &node_ref.edges {
-            if let Some((consumed, captured)) = edge.pattern.matches(remaining) {
-                // Save state for potential backtracking
-                let saved_cursor = state.cursor;
-                let saved_node = state.current_node_id;
-                let saved_prefix = state.current_prefix.clone();
-                
-                // Update state
-                state.cursor += consumed;
-                state.current_node_id = edge.target;
-                state.current_prefix.push_str(&remaining[..consumed]);
-                
-                // Store captured variable if any
-                if let Some(value) = captured {
-                    if let EdgePattern::Variable(var_name) = &edge.pattern {
-                        state.context.insert(var_name.to_string(), value);
-                    }
-                }
-                
-                // Check if this is a partial match that needs completion
-                if state.cursor == state.input.len() {
-                    if let Some(completion_suffix) = edge.pattern.completion(remaining) {
-                        // Use the current_prefix plus the completion suffix
-                        let full_completion = format!("{}{}", 
-                            state.current_prefix, 
-                            completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)
-                        );
-                        return UIAction::suggest(
-                            state.original_query.clone(),
-                            Some(Suggestion {
-                                text: full_completion.clone(),
-                                completion: full_completion,
-                                description: edge.description.map(|d| d.to_string()),
-                                score: 1.0,
-                            })
-                        );
-                    }
-                }
-                
-                // Continue parsing from the target node
-                let result = self.parse_recursive(state);
-                
-                // If we got a valid response, return it
-                if !matches!(result, UIAction::ShowError(_)) {
-                    return result;
-                }
-                
-                // Otherwise, restore state and try next edge
-                state.cursor = saved_cursor;
-                state.current_node_id = saved_node;
-                state.current_prefix = saved_prefix;
-            }
-        }
-        
-        // No edges matched - try to provide suggestions
-        self.suggest_from_edges(&node_ref, state)
-    }
-    
-    fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {
-        let remaining = &state.input[state.cursor..];
-        
-        // Find edges that could match with more input
-        for edge in &node.edges {
-            match &edge.pattern {
-                EdgePattern::PrefixOf(target) => {
-                    if target.starts_with(remaining) && !remaining.is_empty() {
-                        // Use current_prefix instead of rebuilding from input
-                        let full_completion = format!("{}{}", state.current_prefix, target);
-                        return UIAction::suggest(
-                            state.original_query.clone(),
-                            Some(Suggestion {
-                                text: full_completion.clone(),
-                                completion: full_completion,
-                                description: edge.description.map(|d| d.to_string()),
-                                score: 1.0,
-                            })
-                        );
-                    }
-                }
-                EdgePattern::Literal(lit) => {
-                    if lit.starts_with(remaining) && !remaining.is_empty() {
-                        let full_completion = format!("{}{}", state.current_prefix, lit);
-                        return UIAction::suggest(
-                            state.original_query.clone(),
-                            Some(Suggestion {
-                                text: full_completion.clone(),
-                                completion: full_completion,
-                                description: edge.description.map(|d| d.to_string()),
-                                score: 1.0,
-                            })
-                        );
-                    }
-                }
-                _ => {}
-            }
-        }
-        
-        UIAction::error(
-            "InvalidPath".to_string(),
-            format!("'{}' doesn't match any known pattern", state.original_query)
-        )
-    }
-}
-
-struct ParserState<'a> {
-    input: &'a str,
-    cursor: usize,
-    current_node_id: NodeId,
-    context: HashMap,
-    original_query: String,
-    current_prefix: String,
-}
-
-// --- Helper Functions ---
-
-fn is_valid_variable(var_name: &str, value: &str) -> bool {
-    match var_name {
-        "subreddit" => {
-            !value.is_empty() && 
-            value.len() <= 21 && 
-            value.chars().all(|c| c.is_alphanumeric() || c == '_')
-        }
-        "username" => {
-            !value.is_empty() && 
-            value.len() <= 20 && 
-            value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')
-        }
-        "post_id" => {
-            !value.is_empty() && 
-            value.len() <= 10 && 
-            value.chars().all(|c| c.is_alphanumeric())
-        }
-        _ => true, // Allow any value for unknown variables
-    }
-}
-
-// --- Define the Reddit Graph ---
-
-pub fn build_reddit_graph() -> Graph {
-    GraphBuilder::new()
-        // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===
-        .at("root")
-            // Direct aliases to subreddit and user selection
-            .edge_with_desc(
-                EdgePattern::PrefixOf("r/"),
-                "subreddit_selection",
-                Some("Browse subreddits (e.g., r/programming)")
-            )
-            .edge_with_desc(
-                EdgePattern::PrefixOf("u/"),
-                "user_selection",
-                Some("Browse users (e.g., u/spez)")
-            )
-            
-            // Reddit shortcuts - one pattern handles ALL prefixes!
-            .edge_with_desc(
-                EdgePattern::PrefixOf("reddit.com"), 
-                "reddit_domain",
-                Some("Go to Reddit")
-            )
-            
-            // Protocol patterns - "h" can suggest "https://"
-            .edge_with_desc(
-                EdgePattern::PrefixOf("https://"),
-                "https_protocol",
-                Some("HTTPS protocol")
-            )
-            .edge_with_desc(
-                EdgePattern::PrefixOf("http://"),
-                "http_protocol", 
-                Some("HTTP protocol")
-            )
-            .edge_with_desc(
-                EdgePattern::PrefixOf("www."),
-                "www_prefix",
-                Some("World Wide Web")
-            )
-        
-
-        
-        // === HTTPS PROTOCOL: Can go to any domain ===
-        .at("https_protocol")
-            .edge_with_desc(
-                EdgePattern::PrefixOf("reddit.com"),
-                "reddit_domain",
-                Some("Reddit (HTTPS)")
-            )
-            .edge_with_desc(
-                EdgePattern::PrefixOf("www."),
-                "https_www",
-                Some("WWW prefix")
-            )
-        
-        // === HTTP PROTOCOL: Similar to HTTPS ===
-        .at("http_protocol")
-            .edge_with_desc(
-                EdgePattern::PrefixOf("reddit.com"),
-                "reddit_domain",
-                Some("Reddit (HTTP)")
-            )
-            .edge_with_desc(
-                EdgePattern::PrefixOf("www."),
-                "http_www",
-                Some("WWW prefix")
-            )
-        
-        // === HTTPS + WWW ===
-        .at("https_www")
-            .edge_with_desc(
-                EdgePattern::PrefixOf("reddit.com"),
-                "reddit_domain",
-                Some("Reddit")
-            )
-        
-        // === HTTP + WWW ===
-        .at("http_www")
-            .edge_with_desc(
-                EdgePattern::PrefixOf("reddit.com"),
-                "reddit_domain",
-                Some("Reddit")
-            )
-        
-        // === WWW PREFIX (without protocol) ===
-        .at("www_prefix")
-            .edge_with_desc(
-                EdgePattern::PrefixOf("reddit.com"),
-                "reddit_domain",
-                Some("Reddit")
-            )
-        
-        // === REDDIT DOMAIN: Expect "/" ===
-        .at("reddit_domain")
-            .edge(EdgePattern::Literal("/"), "reddit_root")
-            .handler(|query, prefix, _ctx| {
-                // If someone just types "reddit.com" (or with protocol) without slash
-                // Suggest adding the slash using the current prefix
-                let completion = format!("{}/", prefix);
-                
-                UIAction::suggest(
-                    query.to_string(),
-                    Some(Suggestion {
-                        text: completion.clone(),
-                        completion,
-                        description: Some("Continue to Reddit homepage".to_string()),
-                        score: 1.0,
-                    })
-                )
-            })
-        
-        // === REDDIT ROOT: The main Reddit navigation ===
-        .at("reddit_root")
-            .edge(EdgePattern::Literal("r/"), "subreddit_selection")
-            .edge(EdgePattern::Literal("u/"), "user_selection")
-            .handler(|query, prefix, _ctx| {
-                UIAction::multiple(vec![
-                    UIAction::scrolling_suggestions(
-                        query.to_string(),
-                        vec![
-                            ScrollingSuggestion {
-                                completion: format!("{}r/", prefix),
-                            },
-                            ScrollingSuggestion {
-                                completion: format!("{}u/", prefix),
-                            },
-                        ],
-                        1400, // 1.4 second interval (slower)
-                        true  // loop through
-                    ),
-                    UIAction::guide(
-                        query.to_string(),
-                        "Welcome to Sorter for Reddit".to_string(),
-                        "Where would you like to start?".to_string(),
-                        vec![
-                            GuideOption { 
-                                key: "r".to_string(), 
-                                label: "Sort a Subreddit".to_string(), 
-                                description: "Find the best posts in a community.".to_string(), 
-                                completion: format!("{}r/", prefix)
-                            },
-                            GuideOption { 
-                                key: "u".to_string(), 
-                                label: "Sort User Content".to_string(), 
-                                description: "Explore and rank a user's posts and comments.".to_string(), 
-                                completion: format!("{}u/", prefix)
-                            },
-                        ]
-                    ),
-                ])
-            })
-        
-
-        
-        // === SUBREDDIT SELECTION: THE UNIFIED NODE ===
-        // This node is now reached from `r/` OR `reddit.com/r/`
-        .at("subreddit_selection")
-            .edge(EdgePattern::Variable("subreddit"), "subreddit_page")
-            .handler(|query, prefix, _ctx| {
-                UIAction::multiple(vec![
-                    UIAction::scrolling_suggestions(
-                        query.to_string(),
-                        vec![
-                            ScrollingSuggestion {
-                                completion: format!("{}programming", prefix),
-                            },
-                            ScrollingSuggestion {
-                                completion: format!("{}askreddit", prefix),
-                            },
-                            ScrollingSuggestion {
-                                completion: format!("{}aww", prefix),
-                            },
-                            ScrollingSuggestion {
-                                completion: format!("{}rust", prefix),
-                            },
-                            ScrollingSuggestion {
-                                completion: format!("{}webdev", prefix),
-                            },
-                        ],
-                        1600, // 1.6 second interval (slower)
-                        true  // loop through
-                    ),
-                    // Live DB-backed suggestions for subreddits as the user types
-                    UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }
-                ])
-            })
-        
-        // === Specific subreddit page ===
-        .at("subreddit_page")
-            .edge(EdgePattern::Literal("/"), "subreddit_slash")
-            .handler(|_query, prefix, ctx| {
-                // Use the new unified subreddit resolution logic
-                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
-                UIAction::ResolveAndDisplaySubreddit {
-                    subreddit,
-                    prefix: prefix.to_string(),
-                }
-            })
-        
-        .at("subreddit_slash")
-            .edge(EdgePattern::Literal("hot"), "subreddit_hot")
-            .edge(EdgePattern::Literal("top"), "subreddit_top")
-            .edge(EdgePattern::Literal("new"), "subreddit_new")
-            .edge(EdgePattern::Literal("comments"), "subreddit_comments")
-            .handler(|query, prefix, ctx| {
-                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
-                UIAction::guide(
-                    query.to_string(), 
-                    format!("What to sort in r/{}?", subreddit), 
-                    "Choose a category to begin sorting.".to_string(), 
-                    vec![
-                        GuideOption { 
-                            key: "hot".to_string(), 
-                            label: "Hot Posts".to_string(), 
-                            description: "Import and sort posts currently on the front page.".to_string(), 
-                            completion: format!("{}hot", prefix) 
-                        },
-                        GuideOption { 
-                            key: "top".to_string(), 
-                            label: "Top Posts".to_string(), 
-                            description: "Import and sort the highest-rated posts.".to_string(), 
-                            completion: format!("{}top", prefix) 
-                        },
-                        GuideOption { 
-                            key: "new".to_string(), 
-                            label: "New Posts".to_string(), 
-                            description: "Import and sort the newest posts.".to_string(), 
-                            completion: format!("{}new", prefix) 
-                        },
-                        GuideOption { 
-                            key: "comments".to_string(), 
-                            label: "All Comments".to_string(), 
-                            description: "Find the best comment across all imported threads.".to_string(), 
-                            completion: format!("{}comments/", prefix) 
-                        },
-                    ]
-                )
-            })
-        
-
-        
-        .at("user_selection")
-            .edge(EdgePattern::Variable("username"), "user_profile")
-            .handler(|query, prefix, _ctx| {
-                UIAction::guide(
-                    query.to_string(),
-                    "User Profile Sorting".to_string(),
-                    "Enter a Reddit username to sort their content.".to_string(),
-                    vec![
-                        GuideOption {
-                            key: "popular".to_string(),
-                            label: "Popular Users".to_string(),
-                            description: "Browse well-known Reddit users.".to_string(),
-                            completion: prefix.to_string(),
-                        },
-                    ]
-                )
-            })
-        
-        // === Subreddit sort types ===
-        .at("subreddit_hot")
-            .handler(|_query, _prefix, ctx| {
-                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
-                UIAction::RenderEntityView {
-                    ns: "reddit.subreddit".to_string(),
-                    pk: subreddit,
-                }
-            })
-        
-        .at("subreddit_top")
-            .handler(|_query, _prefix, ctx| {
-                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
-                UIAction::RenderEntityView {
-                    ns: "reddit.subreddit".to_string(),
-                    pk: subreddit,
-                }
-            })
-        
-        .at("subreddit_new")
-            .handler(|_query, _prefix, ctx| {
-                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
-                UIAction::RenderEntityView {
-                    ns: "reddit.subreddit".to_string(),
-                    pk: subreddit,
-                }
-            })
-        
-        .at("subreddit_comments")
-            .handler(|_query, _prefix, ctx| {
-                let subreddit = ctx.get("subreddit").cloned().unwrap_or_default();
-                UIAction::RenderEntityView {
-                    ns: "reddit.subreddit".to_string(),
-                    pk: subreddit,
-                }
-            })
-        
-        // === User profile ===
-        .at("user_profile")
-            .handler(|_query, _prefix, ctx| {
-                let username = ctx.get("username").cloned().unwrap_or_default();
-                UIAction::RenderEntityView {
-                    ns: "reddit.user".to_string(),
-                    pk: username,
-                }
-            })
-        
-        // === Build the graph ===
-        .build()
-}
-
-// --- Public API ---
-
-/// Parse a query string and return a UI action
-pub fn parse_reddit_url(query: &str) -> UIAction {
-    let graph = build_reddit_graph();
-    graph.parse(query)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    /// Represents a keystroke action
-    #[derive(Debug, Clone, PartialEq)]
-    enum KeyAction {
-        Type(String),  // Type characters
-        Tab,           // Press tab (accept completion)
-
-    }
-
-    /// Expected state after a keystroke
-    #[derive(Debug, Clone)]
-    enum ExpectedAction {
-        Suggestion { completion: String },
-        ScrollingSuggestions { completions: Vec },
-        Guide { title_contains: String },
-
-        RenderSubreddit { subreddit: String, sort: Option },
-        RenderSubredditComments { subreddit: String },
-        RenderUser { username: String },
-        ResolveSubreddit { subreddit: String, prefix: String },  // New unified subreddit resolution
-        Error { error_type: String },
-        Multiple { expected_actions: Vec },  // Multiple responses with specific expectations
-        MultipleAny,  // Multiple responses (any sub-actions - legacy)
-        DbSuggestions { partial: String, prefix: String },  // Database-backed suggestions
-
-    }
-
-    impl ExpectedAction {
-        fn matches(&self, action: &UIAction) -> bool {
-            match (self, action) {
-                (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {
-                    data.suggestion.as_ref()
-                        .map(|s| s.completion == *completion)
-                        .unwrap_or(false)
-                }
-                (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => {
-                    let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();
-                    *completions == actual_completions
-                }
-                (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => {
-                    title.contains(title_contains)
-                }
-                (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, 
-                 UIAction::RenderEntityView { ns, pk }) => {
-                    ns == "reddit.subreddit" && pk == subreddit
-                }
-                (ExpectedAction::RenderSubredditComments { subreddit }, 
-                 UIAction::RenderEntityView { ns, pk }) => {
-                    ns == "reddit.subreddit" && pk == subreddit
-                }
-                (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {
-                    ns == "reddit.user" && pk == username
-                }
-                (ExpectedAction::ResolveSubreddit { subreddit, prefix }, 
-                 UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {
-                    s == subreddit && p == prefix
-                }
-                (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {
-                    data.error_type == *error_type
-                }
-                (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => {
-                    // Check that all expected actions are present
-                    if expected_actions.len() != actions.len() {
-                        return false;
-                    }
-                    expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {
-                        expected.matches(actual)
-                    })
-                }
-                (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true,
-                (ExpectedAction::DbSuggestions { partial, prefix }, 
-                 UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {
-                    p == partial && pr == prefix
-                }
-
-                _ => false,
-            }
-        }
-    }
-
-    /// Test helper to simulate a sequence of keystrokes
-    fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> {
-        let graph = build_reddit_graph();
-        let mut current_text = String::new();
-        let mut results = Vec::new();
-        
-        for action in actions {
-            match action {
-                KeyAction::Type(text) => {
-                    current_text.push_str(&text);
-                    let result = graph.parse(¤t_text);
-                    results.push((current_text.clone(), result));
-                }
-                KeyAction::Tab => {
-                    // Tab accepts the current suggestion if there is one
-                    let result = graph.parse(¤t_text);
-                    if let UIAction::ShowSuggestions(ref data) = result {
-                        if let Some(ref suggestion) = data.suggestion {
-                            current_text = suggestion.completion.clone();
-                            let new_result = graph.parse(¤t_text);
-                            results.push((current_text.clone(), new_result));
-                        }
-                    }
-                }
-
-            }
-        }
-        
-        results
-    }
-
-    /// Test a flow using declarative (KeyAction, ExpectedAction) tuples
-    fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {
-        let graph = build_reddit_graph();
-        let mut current_text = String::new();
-        
-        println!("\n=== Flow: {} ===", name);
-        
-        for (i, (key_action, expected)) in flow.iter().enumerate() {
-            // Perform the keystroke
-            match key_action {
-                KeyAction::Type(text) => {
-                    current_text.push_str(text);
-                }
-                KeyAction::Tab => {
-                    // Tab accepts the current suggestion
-                    let result = graph.parse(¤t_text);
-                    if let UIAction::ShowSuggestions(data) = result {
-                        if let Some(suggestion) = &data.suggestion {
-                            current_text = suggestion.completion.clone();
-                        }
-                    }
-                }
-            }
-            
-            // Check the result
-            let actual_action = graph.parse(¤t_text);
-            
-            println!("  Step {}: {:?} -> '{}' -> {:?}", 
-                     i + 1, key_action, current_text, actual_action);
-            
-            assert!(
-                expected.matches(&actual_action),
-                "Flow '{}' failed at step {}\n  Expected: {:?}\n  Actual: {:?}\n  Text: '{}'",
-                name, i + 1, expected, actual_action, current_text
-            );
-        }
-        
-        println!("✓ Flow '{}' passed!", name);
-    }
-    
-    /// Legacy helper for backward compatibility (will be removed)
-    fn assert_flow(
-        name: &str,
-        actions: Vec,
-        expected_checks: Vec bool>>,
-    ) {
-        let results = simulate_keystrokes(actions);
-        
-        println!("\n=== Flow: {} ===", name);
-        for (i, (text, action)) in results.iter().enumerate() {
-            println!("  Step {}: '{}' -> {:?}", i + 1, text, action);
-            
-            if i < expected_checks.len() {
-                let check = &expected_checks[i];
-                assert!(
-                    check(text, action),
-                    "Flow '{}' failed at step {} with text '{}' and action {:?}",
-                    name, i + 1, text, action
-                );
-            }
-        }
-        println!("✓ Flow '{}' passed!", name);
-    }
-
-    #[test]
-    fn test_https_reddit_tab_flow() {
-        // Test: typing "https://reddit.com" and pressing tab should give "https://reddit.com/"
-        assert_flow(
-            "HTTPS Reddit with Tab",
-            vec![
-                KeyAction::Type("https://reddit.com".to_string()),
-                KeyAction::Tab,
-            ],
-            vec![
-                Box::new(|text, action| {
-                    // After typing "https://reddit.com", should get a suggestion
-                    text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if 
-                        data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com/").unwrap_or(false)
-                    )
-                }),
-                Box::new(|text, action| {
-                    // After tab, should have "https://reddit.com/" and show guide
-                    text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. })
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_quick_subreddit_flow() {
-        // Test: "r" -> TAB -> "rust" (direct alias flow)
-        assert_flow(
-            "Quick Subreddit Access",
-            vec![
-                KeyAction::Type("r".to_string()),
-                KeyAction::Tab,
-                KeyAction::Type("rust".to_string()),
-            ],
-            vec![
-                Box::new(|text, action| {
-                    // "r" should suggest "r/"
-                    text == "r" && matches!(action, UIAction::ShowSuggestions(data) if 
-                        data.suggestion.as_ref().map(|s| s.completion == "r/").unwrap_or(false)
-                    )
-                }),
-                Box::new(|text, action| {
-                    // After tab, should have "r/" and show subreddit selection
-                    text == "r/" && matches!(action, UIAction::ShowMultiple { .. })
-                }),
-                Box::new(|text, action| {
-                    // "r/rust" should resolve the subreddit
-                    text == "r/rust" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "rust" && prefix == "r/rust")
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_progressive_completion_flow() {
-        // Test progressive typing: "r" -> "re" -> "red" -> "redd" -> "reddit" -> TAB
-        let progressive_actions = vec![
-            KeyAction::Type("r".to_string()),
-            KeyAction::Type("e".to_string()),
-            KeyAction::Type("d".to_string()),
-            KeyAction::Type("d".to_string()),
-            KeyAction::Type("i".to_string()),
-            KeyAction::Type("t".to_string()),
-            KeyAction::Tab,
-        ];
-        
-        let results = simulate_keystrokes(progressive_actions);
-        
-        println!("\n=== Progressive Completion Flow ===");
-        for (i, (text, action)) in results.iter().enumerate() {
-            println!("  '{}' -> {:?}", text, action);
-            
-            // First step: "r" should suggest "r/"
-            if i == 0 && text == "r" {
-                match action {
-                    UIAction::ShowSuggestions(data) => {
-                        assert_eq!(
-                            data.suggestion.as_ref().unwrap().completion,
-                            "r/",
-                            "Should suggest r/ at 'r'"
-                        );
-                    }
-                    _ => panic!("Expected suggestion at 'r'"),
-                }
-            }
-            // Other steps before tab should suggest "reddit.com"
-            else if i > 0 && i < results.len() - 1 {
-                match action {
-                    UIAction::ShowSuggestions(data) => {
-                        assert_eq!(
-                            data.suggestion.as_ref().unwrap().completion,
-                            "reddit.com",
-                            "Should suggest reddit.com at '{}'", text
-                        );
-                    }
-                    _ => panic!("Expected suggestion at '{}'", text),
-                }
-            }
-        }
-        
-        // After tab, should have "reddit.com"
-        let (final_text, _) = results.last().unwrap();
-        assert_eq!(final_text, "reddit.com");
-        println!("✓ Progressive completion flow passed!");
-    }
-
-    #[test]
-    fn test_subreddit_sort_flow() {
-        // Test navigating to a subreddit and choosing a sort option
-        assert_flow(
-            "Subreddit Sort Navigation",
-            vec![
-                KeyAction::Type("reddit.com/r/programming/hot".to_string()),
-            ],
-            vec![
-                Box::new(|text, action| {
-                    text == "reddit.com/r/programming/hot" && 
-                    matches!(action, UIAction::RenderEntityView { ns, pk } 
-                        if ns == "reddit.subreddit" && pk == "programming")
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_user_profile_flow() {
-        // Test navigating to a user profile
-        assert_flow(
-            "User Profile Navigation",
-            vec![
-                KeyAction::Type("reddit.com/u/spez".to_string()),
-            ],
-            vec![
-                Box::new(|text, action| {
-                    text == "reddit.com/u/spez" && 
-                    matches!(action, UIAction::RenderEntityView { ns, pk } if ns == "reddit.user" && pk == "spez")
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_alias_shortcut_flow() {
-        // Test using the "r/" shortcut - now correctly goes directly to subreddit
-        assert_flow(
-            "Alias Shortcut",
-            vec![
-                KeyAction::Type("r/".to_string()),
-                KeyAction::Type("technology".to_string()),
-            ],
-            vec![
-                Box::new(|text, action| {
-                    text == "r/" && matches!(action, UIAction::ShowMultiple { .. })
-                }),
-                Box::new(|text, action| {
-                    text == "r/technology" && 
-                    matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "technology" && prefix == "r/technology")
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_www_prefix_flow() {
-        // Test with www prefix
-        assert_flow(
-            "WWW Prefix",
-            vec![
-                KeyAction::Type("www.reddit.com".to_string()),
-                KeyAction::Tab,
-            ],
-            vec![
-                Box::new(|text, action| {
-                    // Should suggest adding slash
-                    text == "www.reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if 
-                        data.suggestion.as_ref().map(|s| s.completion == "www.reddit.com/").unwrap_or(false)
-                    )
-                }),
-                Box::new(|text, action| {
-                    // After tab, should show guide
-                    text == "www.reddit.com/" && matches!(action, UIAction::ShowMultiple { .. })
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_invalid_path_handling() {
-        // Test that invalid paths show errors
-        assert_flow(
-            "Invalid Path",
-            vec![
-                KeyAction::Type("reddit.com/invalid/path".to_string()),
-            ],
-            vec![
-                Box::new(|text, action| {
-                    text == "reddit.com/invalid/path" && 
-                    matches!(action, UIAction::ShowError(_))
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_complete_user_journey() {
-        // Test a complete user journey: type partial URL, tab complete, navigate to subreddit
-        assert_flow(
-            "Complete User Journey",
-            vec![
-                KeyAction::Type("http".to_string()),
-                KeyAction::Type("s://r".to_string()),
-                KeyAction::Tab,
-                KeyAction::Type("/".to_string()),
-                KeyAction::Type("r/".to_string()),
-                KeyAction::Type("programming".to_string()),
-                KeyAction::Type("/".to_string()),
-                KeyAction::Type("top".to_string()),
-            ],
-            vec![
-                Box::new(|text, action| {
-                    // "http" should suggest "https://"
-                    text == "http" && matches!(action, UIAction::ShowSuggestions(data) if 
-                        data.suggestion.as_ref().map(|s| s.completion == "https://").unwrap_or(false)
-                    )
-                }),
-                Box::new(|text, action| {
-                    // "https://r" should suggest "https://reddit.com"
-                    text == "https://r" && matches!(action, UIAction::ShowSuggestions(data) if 
-                        data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com").unwrap_or(false)
-                    )
-                }),
-                Box::new(|text, action| {
-                    // After tab, should have "https://reddit.com"
-                    text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(_))
-                }),
-                Box::new(|text, action| {
-                    // "https://reddit.com/" should show guide
-                    text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. })
-                }),
-                Box::new(|text, action| {
-                    // "https://reddit.com/r/" should show subreddit selection
-                    text == "https://reddit.com/r/" && matches!(action, UIAction::ShowMultiple { .. })
-                }),
-                Box::new(|text, action| {
-                    // "https://reddit.com/r/programming" should resolve subreddit
-                    text == "https://reddit.com/r/programming" && 
-                    matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } 
-                        if subreddit == "programming" && prefix == "https://reddit.com/r/programming")
-                }),
-                Box::new(|text, action| {
-                    // "https://reddit.com/r/programming/" should show sort options
-                    text == "https://reddit.com/r/programming/" && 
-                    matches!(action, UIAction::ShowStaticGuide { .. })
-                }),
-                Box::new(|text, action| {
-                    // "https://reddit.com/r/programming/top" should render entity view
-                    text == "https://reddit.com/r/programming/top" && 
-                    matches!(action, UIAction::RenderEntityView { ns, pk } 
-                        if ns == "reddit.subreddit" && pk == "programming")
-                }),
-            ],
-        );
-    }
-
-    #[test]
-    fn test_multiple_tab_completions() {
-        // Test multiple tab completions in sequence
-        let actions = vec![
-            KeyAction::Type("h".to_string()),
-            KeyAction::Tab,  // Complete to "https://"
-            KeyAction::Type("r".to_string()),
-            KeyAction::Tab,  // Complete to "https://reddit.com"
-            KeyAction::Type("/".to_string()),
-        ];
-        
-        let results = simulate_keystrokes(actions);
-        
-        println!("\n=== Multiple Tab Completions ===");
-        for (i, (text, _action)) in results.iter().enumerate() {
-            println!("  Step {}: '{}'", i + 1, text);
-        }
-        
-        // Verify the final state
-        assert_eq!(results[1].0, "https://");  // After first tab
-        assert_eq!(results[3].0, "https://reddit.com");  // After second tab
-        assert_eq!(results[4].0, "https://reddit.com/");  // After typing /
-        
-        println!("✓ Multiple tab completions work correctly!");
-    }
-
-    // === CONVENIENCE MACROS FOR CLEANER TESTS ===
-    
-    macro_rules! flow {
-        ($(($key:expr, $expected:expr)),* $(,)?) => {
-            vec![$(($key, $expected)),*]
-        };
-    }
-    
-    macro_rules! type_text {
-        ($text:expr) => {
-            KeyAction::Type($text.to_string())
-        };
-    }
-    
-    macro_rules! suggests {
-        ($completion:expr) => {
-            ExpectedAction::Suggestion { completion: $completion.to_string() }
-        };
-    }
-    
-    macro_rules! renders_subreddit {
-        ($subreddit:expr) => {
-            ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }
-        };
-        ($subreddit:expr, $sort:expr) => {
-            ExpectedAction::RenderSubreddit { 
-                subreddit: $subreddit.to_string(), 
-                sort: Some($sort.to_string()) 
-            }
-        };
-    }
-    
-    macro_rules! renders_subreddit_comments {
-        ($subreddit:expr) => {
-            ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }
-        };
-    }
-    
-    macro_rules! resolves_subreddit {
-        ($subreddit:expr, $prefix:expr) => {
-            ExpectedAction::ResolveSubreddit { 
-                subreddit: $subreddit.to_string(),
-                prefix: $prefix.to_string()
-            }
-        };
-    }
-    
-    macro_rules! shows_guide {
-        ($title_contains:expr) => {
-            ExpectedAction::Guide { title_contains: $title_contains.to_string() }
-        };
-    }
-    
-    macro_rules! multiple {
-        ($($action:expr),* $(,)?) => {
-            ExpectedAction::Multiple { expected_actions: vec![$($action),*] }
-        };
-    }
-    
-    macro_rules! scrolling_suggestions {
-        ($($completion:expr),* $(,)?) => {
-            ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }
-        };
-    }
-    
-    macro_rules! db_suggestions {
-        ($partial:expr, $prefix:expr) => {
-            ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }
-        };
-    }
-
-    // === NEW DECLARATIVE TESTS ===
-
-    #[test]
-    fn test_declarative_https_tab_flow() {
-        test_flow("HTTPS Tab Completion", vec![
-            (KeyAction::Type("https://reddit.com".to_string()), 
-             ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }),
-            (KeyAction::Tab, 
-             multiple![
-                 scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"),
-                 shows_guide!("Welcome to Sorter")
-             ]),
-        ]);
-    }
-
-    #[test]
-    fn test_declarative_quick_subreddit_flow() {
-        test_flow("Quick Subreddit Flow", vec![
-            (KeyAction::Type("r".to_string()), 
-             ExpectedAction::Suggestion { completion: "r/".to_string() }),
-            (KeyAction::Tab, 
-             ExpectedAction::MultipleAny), // r/ shows subreddit selection
-            (KeyAction::Type("rust".to_string()),
-             ExpectedAction::ResolveSubreddit { subreddit: "rust".to_string(), prefix: "r/rust".to_string() }),
-        ]);
-    }
-
-    #[test]
-    fn test_declarative_complete_journey() {
-        test_flow("Complete User Journey", vec![
-            (KeyAction::Type("http".to_string()),
-             ExpectedAction::Suggestion { completion: "https://".to_string() }),
-            (KeyAction::Type("s://r".to_string()),
-             ExpectedAction::Suggestion { completion: "https://reddit.com".to_string() }),
-            (KeyAction::Tab,
-             ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }),
-            (KeyAction::Type("/r/programming/top".to_string()),
-             ExpectedAction::RenderSubreddit { 
-                 subreddit: "programming".to_string(), 
-                 sort: Some("top".to_string()) 
-             }),
-        ]);
-    }
-
-    #[test]
-    fn test_declarative_user_profile() {
-        test_flow("User Profile Navigation", vec![
-            (KeyAction::Type("reddit.com/u/spez".to_string()),
-             ExpectedAction::RenderUser { username: "spez".to_string() }),
-        ]);
-    }
-
-    #[test]
-    fn test_declarative_error_handling() {
-        test_flow("Error Handling", vec![
-            (KeyAction::Type("reddit.com/invalid/path".to_string()),
-             ExpectedAction::Error { error_type: "InvalidPath".to_string() }),
-        ]);
-    }
-
-    #[test]
-    fn test_declarative_progressive_completion() {
-        test_flow("Progressive Completion", vec![
-            (KeyAction::Type("r".to_string()),
-             ExpectedAction::Suggestion { completion: "r/".to_string() }),
-            (KeyAction::Type("e".to_string()),
-             ExpectedAction::Suggestion { completion: "reddit.com".to_string() }),
-            (KeyAction::Type("d".to_string()),
-             ExpectedAction::Suggestion { completion: "reddit.com".to_string() }),
-            (KeyAction::Type("dit".to_string()),
-             ExpectedAction::Suggestion { completion: "reddit.com".to_string() }),
-            (KeyAction::Tab,
-             ExpectedAction::Suggestion { completion: "reddit.com/".to_string() }),
-        ]);
-    }
-
-    #[test]
-    fn test_declarative_subreddit_guide() {
-        test_flow("Subreddit Guide", vec![
-            (KeyAction::Type("reddit.com/r/programming/".to_string()),
-             ExpectedAction::Guide { title_contains: "What to sort".to_string() }),
-        ]);
-    }
-
-    #[test]
-    fn test_clean_macro_example() {
-        // This is what the tests can look like with macros!
-        test_flow("Clean Macro Example", flow![
-            (type_text!("r"), suggests!("r/")),
-            (KeyAction::Tab, ExpectedAction::MultipleAny),
-            (type_text!("rust/hot"), renders_subreddit!("rust", "hot")),
-        ]);
-    }
-
-    #[test]
-    fn test_unified_subreddit_resolution_flow() {
-        // This test demonstrates the new unified behavior:
-        // - r/programming (exact) → tries exact match first
-        // - r/pro (partial) → tries exact match, then suggestions + TAB completion
-        
-        test_flow("Unified Resolution: Exact Match", flow![
-            (type_text!("r/programming"), resolves_subreddit!("programming", "r/programming")),
-        ]);
-        
-        test_flow("Unified Resolution: Partial Match", flow![
-            (type_text!("r/pro"), resolves_subreddit!("pro", "r/pro")),
-        ]);
-        
-        // Both go through the same action type, but dispatcher handles them differently:
-        // - If "programming" exists in DB → shows EntityView immediately
-        // - If "pro" doesn't exist in DB → shows Multiple with:
-        //   1. Suggestions (for TAB completion to best match)  
-        //   2. Selection (for clickable options including import)
-    }
-
-    #[test]
-    fn test_tab_completion_workflow_unified() {
-        // This demonstrates the desired TAB completion behavior:
-        // r/pro + TAB → r/programming (if "programming" is the best DB match)
-        
-        // Note: This test shows the PARSER behavior. The actual TAB completion 
-        // happens in the frontend when it receives the Multiple response containing
-        // both Suggestions (for TAB) and Selection (for click options).
-        
-        let graph = build_reddit_graph();
-        
-        // 1. Parser generates unified action for partial input
-        match graph.parse("r/pro") {
-            UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {
-                assert_eq!(subreddit, "pro");
-                assert_eq!(prefix, "r/pro");
-                println!("✓ Parser correctly identifies 'r/pro' as subreddit resolution");
-            }
-            _ => panic!("Expected ResolveAndDisplaySubreddit for 'r/pro'"),
-        }
-        
-        // 2. When dispatcher runs (in real app), it will return Multiple response with:
-        //    - Suggestions: { completion: "r/programming" } (for TAB)
-        //    - Selection: [ "Import r/pro", "r/programming", ... ] (for clicks)
-        
-        println!("✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)");
-    }
-
-    #[test]
-    fn test_ultra_clean_user_journey() {
-        test_flow("Ultra Clean User Journey", flow![
-            (type_text!("h"), suggests!("https://")),
-            (type_text!("ttps://r"), suggests!("https://reddit.com")), 
-            (KeyAction::Tab, suggests!("https://reddit.com/")),
-            (type_text!("/u/spez"), ExpectedAction::RenderUser { username: "spez".to_string() }),
-        ]);
-    }
-
-    // === TESTS FROM parser.tdsl ===
-
-    #[test]
-    fn test_tdsl_basic_reddit_progression() {
-        // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps
-        test_flow("TDSL Basic Reddit Progression", flow![
-            (type_text!("r"), suggests!("r/")),
-            (type_text!("e"), suggests!("reddit.com")),
-            (type_text!("d"), suggests!("reddit.com")),
-            (type_text!("d"), suggests!("reddit.com")),
-            (type_text!("i"), suggests!("reddit.com")),
-            (type_text!("t"), suggests!("reddit.com")),
-            (type_text!("."), suggests!("reddit.com")),
-            (type_text!("c"), suggests!("reddit.com")),
-            (type_text!("o"), suggests!("reddit.com")),
-            (KeyAction::Tab, suggests!("reddit.com/")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_reddit_com_slash_infographic() {
-        // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}
-        test_flow("TDSL Reddit.com/ Infographic", flow![
-            (type_text!("reddit.com/"), ExpectedAction::MultipleAny),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_subreddit_selection() {
-        // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}
-        test_flow("TDSL Subreddit Selection", flow![
-            (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_subreddit_view() {
-        // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}
-        test_flow("TDSL Subreddit View", flow![
-            (type_text!("reddit.com/r/programming"), resolves_subreddit!("programming", "reddit.com/r/programming")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_subreddit_slash_infographic() {
-        // reddit.com/r/{sub}/ -> {show infographic or something}
-        test_flow("TDSL Subreddit Slash Infographic", flow![
-            (type_text!("reddit.com/r/programming/"), shows_guide!("What to sort")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_subreddit_comments() {
-        // reddit.com/r/{sub}/comments/{randomly chose comment from sql}
-        test_flow("TDSL Subreddit Comments", flow![
-            (type_text!("reddit.com/r/programming/comments"), renders_subreddit_comments!("programming")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_h_to_https() {
-        // h->https:// (show supported domains)
-        test_flow("TDSL H to HTTPS", flow![
-            (type_text!("h"), suggests!("https://")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_composable_https_reddit() {
-        // https://r->https://reddit.com/
-        test_flow("TDSL Composable HTTPS Reddit", flow![
-            (type_text!("https://r"), suggests!("https://reddit.com")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_composable_https_www() {
-        // https://w->https://www.
-        test_flow("TDSL Composable HTTPS WWW", flow![
-            (type_text!("https://w"), suggests!("https://www.")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_composable_https_www_reddit() {
-        // https://www.r->https://www.reddit.com/
-        test_flow("TDSL Composable HTTPS WWW Reddit", flow![
-            (type_text!("https://www.r"), suggests!("https://www.reddit.com")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_full_composable_chain() {
-        // Complete chain showing composability: h -> https:// -> https://www.reddit.com
-        test_flow("TDSL Full Composable Chain Step 1", flow![
-            (type_text!("h"), suggests!("https://")),
-        ]);
-        
-        test_flow("TDSL Full Composable Chain Step 2", flow![
-            (type_text!("https://w"), suggests!("https://www.")),
-        ]);
-        
-        test_flow("TDSL Full Composable Chain Step 3", flow![
-            (type_text!("https://www.r"), suggests!("https://www.reddit.com")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_progressive_reddit_paths() {
-        // Test various reddit paths work as expected
-        test_flow("TDSL Progressive Reddit Paths", flow![
-            (type_text!("reddit.com"), suggests!("reddit.com/")),
-            (KeyAction::Tab, ExpectedAction::MultipleAny),
-        ]);
-    }
-
-    #[test] 
-    fn test_tdsl_subreddit_sorting_options() {
-        // Test that subreddit sorting options work as described
-        test_flow("TDSL Subreddit Sorting", flow![
-            (type_text!("reddit.com/r/rust/hot"), renders_subreddit!("rust", "hot")),
-        ]);
-        
-        test_flow("TDSL Subreddit Top", flow![
-            (type_text!("reddit.com/r/rust/top"), renders_subreddit!("rust", "top")),
-        ]);
-        
-        test_flow("TDSL Subreddit New", flow![
-            (type_text!("reddit.com/r/rust/new"), renders_subreddit!("rust", "new")),
-        ]);
-    }
-
-    #[test]
-    fn test_tdsl_all_reddit_prefixes() {
-        // Test all the prefixes mentioned in parser.tdsl work
-        // "r" now suggests "r/", all others suggest "reddit.com"
-        let prefixes_to_reddit = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"];
-        
-        // Test "r" separately since it now suggests "r/"
-        test_flow("TDSL Prefix: r", flow![
-            (type_text!("r"), suggests!("r/")),
-        ]);
-        
-        for prefix in prefixes_to_reddit {
-            test_flow(&format!("TDSL Prefix: {}", prefix), flow![
-                (type_text!(prefix), suggests!("reddit.com")),
-            ]);
-        }
-    }
-
-    #[test]
-    fn test_tdsl_protocol_combinations() {
-        // Test various protocol combinations from parser.tdsl
-        let test_cases = vec![
-            ("http://r", "http://reddit.com"),
-            ("https://r", "https://reddit.com"),
-            ("www.r", "www.reddit.com"),
-            ("https://www.r", "https://www.reddit.com"),
-            ("http://www.r", "http://www.reddit.com"),
-        ];
-        
-        for (input, expected) in test_cases {
-            test_flow(&format!("TDSL Protocol: {}", input), flow![
-                (type_text!(input), suggests!(expected)),
-            ]);
-        }
-    }
-
-    #[test]
-    fn test_tdsl_edge_case_completions() {
-        // Test edge cases mentioned in parser.tdsl
-        test_flow("TDSL Reddit.com completion", flow![
-            (type_text!("reddit.com"), suggests!("reddit.com/")),
-        ]);
-        
-        // Test that typing full reddit.com suggests the slash
-        test_flow("TDSL Full domain completion", flow![
-            (type_text!("reddit.com"), suggests!("reddit.com/")),
-            (KeyAction::Tab, ExpectedAction::MultipleAny),
-        ]);
-    }
-
-    #[test]
-    fn test_real_user_trace_2025_08_07_fixed() {
-        // Based on actual WebSocket trace from 2025-08-07T01:35:46Z
-        // This test shows the CORRECT behavior after fixing the protocol preservation bug
-        test_flow("Real User Trace: Progressive Typing with Tab Completions (Fixed)", flow![
-            // User started typing "h"
-            (type_text!("h"), suggests!("https://")),
-            
-            // User continued to "ht" 
-            (type_text!("t"), suggests!("https://")),
-            
-            // User finished typing "https://" (trace shows full protocol)
-            (type_text!("tps://"), suggests!("https://")),
-            
-            // User started typing "r" after protocol
-            (type_text!("r"), suggests!("https://reddit.com")),
-            
-            // User continued typing "re"
-            (type_text!("e"), suggests!("https://reddit.com")),
-            
-            // User typed out or completed "https://reddit.com"
-            (type_text!("ddit.com"), suggests!("https://reddit.com/")),
-            
-            // User accepted completion to "https://reddit.com/"
-            // NOW suggestions should preserve the https:// protocol
-            (KeyAction::Tab, multiple![
-                scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"),  // This is the key fix!
-                shows_guide!("Welcome to Sorter")
-            ]),
-        ]);
-    }
-
-    #[test]
-    fn test_protocol_preservation_bug_fix() {
-        // This test specifically verifies the fix for the protocol preservation bug
-        test_flow("Protocol Preservation: HTTPS Reddit Homepage", flow![
-            (type_text!("https://reddit.com/"), multiple![
-                scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"),  // Should preserve https://
-                shows_guide!("Welcome to Sorter")
-            ]),
-        ]);
-        
-        // Test that subreddit selection preserves protocol
-        test_flow("Protocol Preservation: HTTPS Subreddit Selection", flow![
-            (type_text!("https://reddit.com/r/"), multiple![
-                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://
-                db_suggestions!("https://reddit.com/r/", "https://reddit.com/r/")
-            ]),
-        ]);
-        
-        // Test with different protocols
-        test_flow("Protocol Preservation: HTTP", flow![
-            (type_text!("http://reddit.com/"), multiple![
-                scrolling_suggestions!("http://reddit.com/r/", "http://reddit.com/u/"),  // Should preserve http://
-                shows_guide!("Welcome to Sorter")
-            ]),
-        ]);
-        
-        test_flow("Protocol Preservation: WWW", flow![
-            (type_text!("www.reddit.com/"), multiple![
-                scrolling_suggestions!("www.reddit.com/r/", "www.reddit.com/u/"),  // Should preserve www.
-                shows_guide!("Welcome to Sorter")
-            ]),
-        ]);
-        
-        // Test that plain reddit.com still works
-        test_flow("Protocol Preservation: Plain Domain", flow![
-            (type_text!("reddit.com/"), multiple![
-                scrolling_suggestions!("reddit.com/r/", "reddit.com/u/"),  // No protocol prefix
-                shows_guide!("Welcome to Sorter")
-            ]),
-        ]);
-    }
-
-    #[test]
-    fn test_user_navigation_pattern() {
-        // Models how users actually navigate: type → tab → click suggestion → end up at destination
-        // This captures the "jump" from https://reddit.com/ to reddit.com/r/ seen in the trace
-        test_flow("User Navigation: Protocol to Domain", flow![
-            // User types and gets to homepage
-            (type_text!("https://reddit.com/"), ExpectedAction::MultipleAny),
-        ]);
-        
-        // Then they navigate (perhaps clicking a suggestion) to subreddit selection
-        test_flow("User Navigation: Click to Subreddit", flow![
-            // This is where they ended up - the suggestion in the guide probably said "reddit.com/r/"
-            (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny),
-        ]);
-    }
-
-    #[test]
-    fn test_progressive_typing_pattern() {
-        // Based on the trace pattern - users often type character by character
-        // This tests the exact sequence of suggestions they would see
-        test_flow("Progressive Typing Pattern", flow![
-            (type_text!("h"), suggests!("https://")),
-            (type_text!("t"), suggests!("https://")), // Still suggests https://
-            (type_text!("t"), suggests!("https://")), // ht -> htt, still suggests https://
-            (type_text!("p"), suggests!("https://")), // http should still suggest https://
-            (type_text!("s"), suggests!("https://")), // https should suggest https://
-            (type_text!("://"), suggests!("https://")), // Even complete protocol still suggests itself
-        ]);
-    }
-
-    #[test]  
-    fn test_tab_completion_workflow() {
-        // Test what happens when user uses tab completions strategically
-        test_flow("Strategic Tab Completion Workflow", flow![
-            // Start typing, get suggestion
-            (type_text!("h"), suggests!("https://")),
-            
-            // Accept suggestion with tab - this should move us to "https://"
-            (KeyAction::Tab, suggests!("https://")), // After tab, we're at "https://" which still suggests itself
-            
-            // Start typing reddit
-            (type_text!("r"), suggests!("https://reddit.com")),
-            
-            // Accept reddit suggestion  
-            (KeyAction::Tab, suggests!("https://reddit.com/")),
-            
-            // Accept final suggestion to get to homepage
-            (KeyAction::Tab, ExpectedAction::MultipleAny),
-        ]);
-    }
-
-    // Keep the original tests as well
-    #[test]
-    fn test_reddit_prefix_autocomplete() {
-        let graph = build_reddit_graph();
-        
-        // "r" now suggests "r/", others suggest "reddit.com"
-        match graph.parse("r") {
-            UIAction::ShowSuggestions(data) => {
-                assert_eq!(data.suggestion.as_ref().unwrap().completion, "r/");
-                println!("✓ 'r' → r/");
-            }
-            _ => panic!("Expected suggestion for 'r'"),
-        }
-        
-        // Test other prefixes that should suggest "reddit.com"
-        let prefixes = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"];
-        
-        for prefix in prefixes {
-            match graph.parse(prefix) {
-                UIAction::ShowSuggestions(data) => {
-                    assert_eq!(data.suggestion.as_ref().unwrap().completion, "reddit.com");
-                    println!("✓ '{}' → reddit.com", prefix);
-                }
-                _ => panic!("Expected suggestion for '{}'", prefix),
-            }
-        }
-    }
-    
-    #[test]
-    fn test_protocol_composition() {
-        let graph = build_reddit_graph();
-        
-        // Test protocol + reddit compositions
-        let tests = vec![
-            ("https://r", "https://reddit.com"),
-            ("https://re", "https://reddit.com"),
-            ("https://reddit", "https://reddit.com"),
-            ("https://www.r", "https://www.reddit.com"),
-            ("https://www.reddit", "https://www.reddit.com"),
-            ("http://r", "http://reddit.com"),
-            ("www.r", "www.reddit.com"),
-        ];
-        
-        for (input, expected) in tests {
-            match graph.parse(input) {
-                UIAction::ShowSuggestions(data) => {
-                    assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);
-                    println!("✓ '{}' → {}", input, expected);
-                }
-                _ => panic!("Expected suggestion for '{}'", input),
-            }
-        }
-    }
-    
-    #[test]
-    fn test_alias_and_full_paths() {
-        let graph = build_reddit_graph();
-        
-        // reddit.com/ should show guide, r/ should show subreddit selection
-        match graph.parse("reddit.com/") {
-            UIAction::ShowMultiple { actions } => {
-                let has_guide = actions.iter()
-                    .any(|a| matches!(a, UIAction::ShowStaticGuide { .. }));
-                assert!(has_guide, "Path 'reddit.com/' should show guide");
-                println!("✓ 'reddit.com/' shows Reddit root guide");
-            }
-            _ => panic!("Expected Multiple action for 'reddit.com/'"),
-        }
-        
-        match graph.parse("r/") {
-            UIAction::ShowMultiple { actions } => {
-                let has_scrolling_suggestions = actions.iter()
-                    .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));
-                let has_db_suggestions = actions.iter()
-                    .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. }));
-                assert!(has_scrolling_suggestions && has_db_suggestions, 
-                       "Path 'r/' should show scrolling suggestions and DB suggestions");
-                println!("✓ 'r/' shows subreddit selection");
-            }
-            _ => panic!("Expected Multiple action for 'r/'"),
-        }
-    }
-    
-    #[test]
-    fn test_deep_navigation() {
-        let graph = build_reddit_graph();
-        
-        // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit
-        match graph.parse("reddit.com/r/rust") {
-            UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {
-                assert_eq!(subreddit, "rust");
-                assert_eq!(prefix, "reddit.com/r/rust");
-                println!("✓ reddit.com/r/rust recognized");
-            }
-            _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit"),
-        }
-        
-        // Test with alias (r/ goes directly to subreddit, no double r/)
-        match graph.parse("r/programming") {
-            UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {
-                assert_eq!(subreddit, "programming");
-                assert_eq!(prefix, "r/programming");
-                println!("✓ r/programming (alias) recognized");
-            }
-            _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit via alias"),
-        }
-    }
-}
diff --git a/parser.tdsl b/parser.tdsl
deleted file mode 100644
index 03ba9563ea8c73b7411cd1aa42dda9c7c313cd39..0000000000000000000000000000000000000000
--- a/parser.tdsl
+++ /dev/null
@@ -1,32 +0,0 @@
-The parser is a series of edges/transitions, for example:
-r -> reddit.com/ 
-where -> means that all of
-  r->reddit.com/
-  re->reddit.com/
-  red->reddit.com/
-  redd->reddit.com/
-  reddi->reddit.com/
-  reddit->reddit.com/
-  reddit.->reddit.com/
-  reddit.c->reddit.com/
-  reddit.co->reddit.com/
-  reddit.com->reddt.com/
-  are defined as autocomplete suggestions
-
-re -> reddit.com/
-reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/
-reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}
-reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/
-reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql}
-
-
-h->https:// (show supported domains)
-
-// the edges are composable such that this is possible, while reusing the above reddit code.
-https://r->https://reddit.com/
-https://w->https://www.
-https://www.r->https://www.reddit.com/
-
-
-// and i also want to have commands after you press space, like
-reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})%                                                                                                                                                
diff --git a/ranking.rs b/ranking.rs
deleted file mode 100644
index 34241cf6bf13117b3ad3262761eb7f7b48b54d80..0000000000000000000000000000000000000000
--- a/ranking.rs
+++ /dev/null
@@ -1,393 +0,0 @@
-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  cat server/src/ranking.rs 
-use std::collections::{HashMap, HashSet};
-
-use crate::path_types::ItemId;
-use crate::reducer::GroupState;
-
-#[derive(Debug, Clone)]
-pub struct RankedItem {
-    pub item: ItemId,
-    pub score: f64,
-}
-
-/// Compute connected components over the voted-pairs graph (treated as undirected).
-///
-/// Returns:
-/// - `components`: each component is a sorted list of node indices, excluding isolates.
-/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).
-pub fn connected_components_from_voted_pairs(
-    n: usize,
-    voted_pairs: impl Iterator,
-) -> (Vec>, Vec) {
-    let mut adj: Vec> = vec![Vec::new(); n];
-    for (a, b) in voted_pairs {
-        if a >= n || b >= n || a == b {
-            continue;
-        }
-        adj[a].push(b);
-        adj[b].push(a);
-    }
-
-    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();
-    isolates.sort();
-
-    let mut seen = vec![false; n];
-    for &i in &isolates {
-        seen[i] = true;
-    }
-
-    let mut comps: Vec> = Vec::new();
-    for i in 0..n {
-        if seen[i] {
-            continue;
-        }
-        let mut stack = vec![i];
-        seen[i] = true;
-        let mut comp: Vec = Vec::new();
-        while let Some(x) = stack.pop() {
-            comp.push(x);
-            for &y in &adj[x] {
-                if !seen[y] {
-                    seen[y] = true;
-                    stack.push(y);
-                }
-            }
-        }
-        comp.sort();
-        comps.push(comp);
-    }
-
-    (comps, isolates)
-}
-
-/// Compute rank centrality scores for a GroupState.
-/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing
-/// an O(E) multiply per iteration.
-pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {
-    if !group.dirty && !group.cached_scores.is_empty() {
-        return;
-    }
-
-    let n = group.idx_to_item.len();
-    if n == 0 {
-        group.cached_scores = vec![];
-        group.dirty = false;
-        return;
-    }
-    if n == 1 {
-        group.cached_scores = vec![1.0];
-        group.dirty = false;
-        return;
-    }
-
-    let scores = compute_scores_from_edges(
-        n,
-        group.edges.iter().map(|(&k, &w)| (k, w)),
-        max_iters,
-        tol,
-    );
-    group.cached_scores = scores;
-    group.dirty = false;
-}
-
-pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {
-    compute_group_ranking(group, max_iters, tol);
-    let mut items: Vec = group
-        .idx_to_item
-        .iter()
-        .enumerate()
-        .map(|(i, item)| RankedItem {
-            item: item.clone(),
-            score: *group.cached_scores.get(i).unwrap_or(&0.0),
-        })
-        .collect();
-
-    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
-    items
-}
-
-fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {
-    if n == 0 {
-        return vec![];
-    }
-    if n == 1 {
-        return vec![1.0];
-    }
-
-    // Collect raw edges into a map for pairwise normalization.
-    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();
-    for ((src, dst), w) in edges {
-        if src >= n || dst >= n || w <= 0.0 {
-            continue;
-        }
-        *raw.entry((src, dst)).or_insert(0.0) += w;
-    }
-
-    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).
-    // This ensures repeated votes on the same pair don't inflate influence
-    // beyond what the ratio implies.
-    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();
-    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();
-    for (i, j) in keys {
-        if normalized.contains_key(&(i, j)) {
-            continue;
-        }
-        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);
-        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);
-        let total = w_ij + w_ji;
-        if total <= 0.0 {
-            continue;
-        }
-        normalized.insert((i, j), w_ij / total);
-        if w_ji > 0.0 {
-            normalized.insert((j, i), w_ji / total);
-        }
-    }
-
-    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):
-    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared
-    //   P_ii = 1 - (1/d_max) * Σ_k A_ik
-    // where d_i is the *degree* (number of distinct neighbors compared) and
-    // d_max = max_i d_i. Using the unweighted degree — not the sum of
-    // pairwise-normalized weights — is what guarantees aperiodicity: it
-    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree
-    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous
-    // loss). Without this, regular comparison graphs (e.g. a pure star at
-    // ratio 2:1) produce a bipartite chain that oscillates instead of
-    // converging — see issue #146.
-    let mut out_edges: Vec> = vec![Vec::new(); n];
-    let mut neighbors: Vec> = vec![HashSet::new(); n];
-
-    for ((src, dst), w) in &normalized {
-        out_edges[*src].push((*dst, *w));
-        neighbors[*src].insert(*dst);
-        neighbors[*dst].insert(*src);
-    }
-
-    let weight_sum: Vec = out_edges
-        .iter()
-        .map(|es| es.iter().map(|(_, w)| *w).sum())
-        .collect();
-    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);
-    if d_max == 0 {
-        return vec![1.0 / n as f64; n];
-    }
-    let d_max_f = d_max as f64;
-
-    let mut scores = vec![1.0 / n as f64; n];
-    let mut next = vec![0.0f64; n];
-
-    for _ in 0..max_iters {
-        next.fill(0.0);
-        for i in 0..n {
-            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;
-            next[i] += scores[i] * stay_prob;
-
-            if out_edges[i].is_empty() {
-                continue;
-            }
-            for &(dst, w) in &out_edges[i] {
-                next[dst] += scores[i] * (w / d_max_f);
-            }
-        }
-
-        let diff: f64 = scores
-            .iter()
-            .zip(next.iter())
-            .map(|(a, b)| (a - b).abs())
-            .sum();
-
-        scores.clone_from_slice(&next);
-        if diff < tol {
-            break;
-        }
-    }
-
-    let sum: f64 = scores.iter().sum();
-    if sum.is_finite() && sum > 0.0 {
-        for s in &mut scores {
-            *s /= sum;
-        }
-    }
-    scores
-}
-
-/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.
-///
-/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.
-pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {
-    if idxs.is_empty() {
-        return vec![];
-    }
-
-    // Map original idx -> compact idx [0..m)
-    let mut map: HashMap = HashMap::with_capacity(idxs.len());
-    for (j, &i) in idxs.iter().enumerate() {
-        map.insert(i, j);
-    }
-
-    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {
-        let s = *map.get(&src)?;
-        let d = *map.get(&dst)?;
-        Some(((s, d), w))
-    });
-
-    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);
-
-    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).
-    let mut items: Vec = idxs
-        .iter()
-        .enumerate()
-        .filter_map(|(j, &orig)| {
-            let item = group.idx_to_item.get(orig)?.clone();
-            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })
-        })
-        .collect();
-
-    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
-    items
-}
-
-pub fn group_summary_scores(
-    group: &mut GroupState,
-    max_iters: usize,
-    tol: f64,
-) -> HashMap {
-    ranked_items(group, max_iters, tol)
-        .into_iter()
-        .map(|r| (r.item, r.score))
-        .collect()
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::reducer::VoteData;
-
-    fn mk_group() -> GroupState {
-        GroupState::new()
-    }
-
-    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {
-        use crate::path_types::ItemId;
-        VoteData {
-            ts,
-            a: ItemId::parse(a).unwrap(),
-            b: ItemId::parse(b).unwrap(),
-            ratio_left: l,
-            ratio_right: r,
-            body: "because".to_string(),
-            principal: "test".to_string(),
-            delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
-            thread_tag: "untagged".to_string(),
-        }
-    }
-
-    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).
-    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the
-    /// chain was bipartite; power iteration oscillated and returned the
-    /// uniform initial distribution after an even number of steps. Using the
-    /// paper's degree-based d_max gives every node a positive self-loop and
-    /// the chain converges to the correct stationary distribution.
-    #[test]
-    fn star_topology_winner_at_top_via_subset() {
-        let mut g = mk_group();
-        g.apply_vote(vote(1, "zebra", "alpha", 2, 1));
-        g.apply_vote(vote(2, "zebra", "beta", 2, 1));
-
-        let mut items: Vec<(usize, String)> = g
-            .idx_to_item
-            .iter()
-            .enumerate()
-            .map(|(i, it)| (i, it.as_str().to_string()))
-            .collect();
-        items.sort_by(|a, b| a.1.cmp(&b.1));
-        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();
-
-        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);
-        for r in &ranked {
-            eprintln!("{}: {}", r.item.as_str(), r.score);
-        }
-        assert_eq!(
-            ranked[0].item.as_str(),
-            "https://slug.social/zebra",
-            "zebra won both votes and should rank #1"
-        );
-    }
-
-    #[test]
-    fn group_ranking_cache_dirty_flow() {
-        let mut g = mk_group();
-        assert!(g.dirty);
-
-        g.apply_vote(vote(1, "a", "b", 3, 1));
-        assert!(g.dirty);
-        assert!(g.cached_scores.is_empty());
-
-        compute_group_ranking(&mut g, 10000, 1e-8);
-        assert!(!g.dirty);
-        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());
-
-        // Recomputing when not dirty should be a no-op.
-        let before = g.cached_scores.clone();
-        compute_group_ranking(&mut g, 10000, 1e-8);
-        assert_eq!(before, g.cached_scores);
-    }
-
-    #[test]
-    fn connected_components_split_disconnected_pairs() {
-        let mut g = mk_group();
-        // Two disconnected edges: (a,b) and (c,d)
-        g.apply_vote(vote(1, "a", "b", 3, 1));
-        g.apply_vote(vote(2, "c", "d", 3, 1));
-
-        let n = g.idx_to_item.len();
-        let (mut comps, isolates) =
-            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());
-        assert!(isolates.is_empty());
-        // Order-independent: sort components by their item names for stable assert.
-        comps.sort_by_key(|c| {
-            c.iter()
-                .map(|&i| g.idx_to_item[i].clone())
-                .collect::>()
-        });
-        assert_eq!(comps.len(), 2);
-        let comp0 = comps[0]
-            .iter()
-            .map(|&i| g.idx_to_item[i].as_str())
-            .collect::>();
-        let comp1 = comps[1]
-            .iter()
-            .map(|&i| g.idx_to_item[i].as_str())
-            .collect::>();
-        assert_eq!(comp0, vec!["https://slug.social/a", "https://slug.social/b"]);
-        assert_eq!(comp1, vec!["https://slug.social/c", "https://slug.social/d"]);
-    }
-
-    #[test]
-    fn subset_ranking_ranks_within_component_only() {
-        let mut g = mk_group();
-        g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b
-        g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c
-
-        let (comps, _) =
-            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());
-        assert_eq!(comps.len(), 2);
-
-        // Rank each component and ensure winner is first within that component.
-        for comp in comps {
-            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);
-            assert_eq!(ranked.len(), 2);
-            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();
-            if names.contains(&"https://slug.social/a") {
-                assert_eq!(names[0], "https://slug.social/a");
-            } else {
-                assert_eq!(names[0], "https://slug.social/d");
-            }
-        }
-    }
-}
-
-
-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  
-
diff --git a/reddit.rs b/reddit.rs
deleted file mode 100644
index da27f6f76482dd27d44cad9d7b844972bf042e64..0000000000000000000000000000000000000000
--- a/reddit.rs
+++ /dev/null
@@ -1,420 +0,0 @@
-use governor::{Quota, RateLimiter, Jitter};
-use nonzero_ext::nonzero;
-use reqwest::{Client, StatusCode};
-use serde::{Deserialize, Serialize};
-use std::sync::Arc;
-use std::time::Duration;
-use anyhow::{Result, Context, anyhow};
-
-/// Reddit API client with built-in rate limiting
-pub struct RedditClient {
-    client: Client,
-    limiter: Arc,
-    user_agent: String,
-}
-
-impl RedditClient {
-    /// Create a new Reddit client with rate limiting
-    /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps
-    /// We'll be conservative and use 50 requests per minute
-    pub fn new() -> Self {
-        // Create rate limiter: 50 requests per minute
-        let quota = Quota::per_minute(nonzero!(50u32));
-        let limiter = Arc::new(RateLimiter::direct(quota));
-        
-        // Create HTTP client with timeout
-        let client = Client::builder()
-            .timeout(Duration::from_secs(30))
-            .build()
-            .expect("Failed to create HTTP client");
-        
-        // Reddit requires a unique user agent
-        let user_agent = format!(
-            "rust:sorter:v{} (by /u/hourLong_arnould)",
-            env!("CARGO_PKG_VERSION")
-        );
-        
-        Self {
-            client,
-            limiter,
-            user_agent,
-        }
-    }
-    
-    /// Fetch hot posts from a subreddit
-    pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result {
-        // Wait for rate limiter
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!("https://www.reddit.com/r/{}/hot.json?limit={}", subreddit, limit);
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Fetch top posts from a subreddit
-    pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!(
-            "https://www.reddit.com/r/{}/top.json?limit={}&t={}",
-            subreddit, limit, time_period
-        );
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Fetch new posts from a subreddit
-    pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!("https://www.reddit.com/r/{}/new.json?limit={}", subreddit, limit);
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Fetch a specific post and its comments
-    pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result> {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!(
-            "https://www.reddit.com/r/{}/comments/{}.json",
-            subreddit, post_id
-        );
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        match response.status() {
-            StatusCode::OK => {
-                let listings: Vec = response
-                    .json()
-                    .await
-                    .context("Failed to parse Reddit response")?;
-                Ok(listings)
-            }
-            StatusCode::TOO_MANY_REQUESTS => {
-                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
-            }
-            StatusCode::NOT_FOUND => {
-                Err(anyhow!("Post not found: r/{}/comments/{}", subreddit, post_id))
-            }
-            status => {
-                Err(anyhow!("Reddit API error: {}", status))
-            }
-        }
-    }
-    
-    /// Fetch user profile (posts and comments)
-    pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!(
-            "https://www.reddit.com/user/{}/{}.json?limit={}",
-            username, content_type, limit
-        );
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Handle Reddit API response with proper error checking
-    async fn handle_response(&self, response: reqwest::Response) -> Result {
-        match response.status() {
-            StatusCode::OK => {
-                let listing: RedditListingResponse = response
-                    .json()
-                    .await
-                    .context("Failed to parse Reddit response")?;
-                Ok(listing)
-            }
-            StatusCode::TOO_MANY_REQUESTS => {
-                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
-            }
-            StatusCode::NOT_FOUND => {
-                Err(anyhow!("Reddit resource not found"))
-            }
-            StatusCode::FORBIDDEN => {
-                Err(anyhow!("Access forbidden. The subreddit may be private."))
-            }
-            status => {
-                Err(anyhow!("Reddit API error: {}", status))
-            }
-        }
-    }
-}
-
-// Reddit API response types
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditListingResponse {
-    pub kind: String,
-    pub data: RedditListingData,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditListingData {
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub after: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub before: Option,
-    pub children: Vec,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub modhash: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditThing {
-    pub kind: String,
-    pub data: RedditThingData,
-}
-
-/// Reddit's "Thing" data can be either a post, comment, or other types
-/// We use untagged enum to handle different data structures
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum RedditThingData {
-    Post(RedditPost),
-    Comment(RedditComment),
-    // For things we don't care about yet (like "more" comments)
-    Other(serde_json::Value),
-}
-
-/// Reddit post data with serde handling all the parsing
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditPost {
-    pub id: String,
-    pub name: String, // Full name (t3_xxx)
-    #[serde(default = "default_untitled")]
-    pub title: String,
-    #[serde(default = "default_deleted_user")]
-    pub author: String,
-    pub subreddit: String,
-    #[serde(default)]
-    pub url: String,
-    #[serde(default)]
-    pub permalink: String,
-    #[serde(default, deserialize_with = "deserialize_selftext")]
-    pub selftext: Option,
-    #[serde(default)]
-    pub score: i64,
-    #[serde(default)]
-    pub num_comments: i64,
-    #[serde(default)]
-    pub created_utc: f64,
-    #[serde(default, deserialize_with = "deserialize_thumbnail")]
-    pub thumbnail: Option,
-    #[serde(default)]
-    pub is_video: bool,
-    #[serde(default)]
-    pub is_self: bool,
-    // Additional useful fields
-    #[serde(default)]
-    pub ups: i64,
-    #[serde(default)]
-    pub downs: i64,
-    #[serde(default)]
-    pub upvote_ratio: f64,
-    #[serde(default)]
-    pub over_18: bool,
-    #[serde(default)]
-    pub spoiler: bool,
-    #[serde(default)]
-    pub stickied: bool,
-    #[serde(default)]
-    pub locked: bool,
-    #[serde(default)]
-    pub distinguished: Option,
-}
-
-/// Reddit comment data
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditComment {
-    pub id: String,
-    pub name: String, // Full name (t1_xxx)
-    #[serde(default = "default_deleted_user")]
-    pub author: String,
-    #[serde(default)]
-    pub body: String,
-    #[serde(default)]
-    pub score: i64,
-    #[serde(default)]
-    pub created_utc: f64,
-    #[serde(default)]
-    pub parent_id: String,
-    #[serde(default)]
-    pub permalink: String,
-    #[serde(default)]
-    pub depth: i32,
-    // Additional useful fields
-    #[serde(default)]
-    pub ups: i64,
-    #[serde(default)]
-    pub downs: i64,
-    #[serde(default)]
-    pub edited: RedditEditStatus,
-    #[serde(default)]
-    pub stickied: bool,
-    #[serde(default)]
-    pub distinguished: Option,
-    #[serde(default)]
-    pub is_submitter: bool,
-    #[serde(default)]
-    pub collapsed: bool,
-    #[serde(default)]
-    pub controversiality: i32,
-}
-
-/// Reddit's edit status - can be false or a timestamp
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum RedditEditStatus {
-    NotEdited(bool),
-    EditedAt(f64),
-}
-
-impl Default for RedditEditStatus {
-    fn default() -> Self {
-        RedditEditStatus::NotEdited(false)
-    }
-}
-
-// Helper functions for serde defaults
-fn default_untitled() -> String {
-    "Untitled".to_string()
-}
-
-fn default_deleted_user() -> String {
-    "[deleted]".to_string()
-}
-
-// Custom deserializer for selftext (empty strings should be None)
-fn deserialize_selftext<'de, D>(deserializer: D) -> Result, D::Error>
-where
-    D: serde::Deserializer<'de>,
-{
-    let s: Option = Option::deserialize(deserializer)?;
-    Ok(s.filter(|text| !text.is_empty()))
-}
-
-// Custom deserializer for thumbnail (filter out "self", "default", empty)
-fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result, D::Error>
-where
-    D: serde::Deserializer<'de>,
-{
-    let s: Option = Option::deserialize(deserializer)?;
-    Ok(s.filter(|thumb| {
-        !thumb.is_empty() && thumb != "self" && thumb != "default" && thumb != "nsfw" && thumb != "spoiler"
-    }))
-}
-
-// Helper methods for extracting typed data from Things
-impl RedditThing {
-    /// Try to get this Thing as a Post
-    pub fn as_post(&self) -> Option<&RedditPost> {
-        if self.kind != "t3" {
-            return None;
-        }
-        match &self.data {
-            RedditThingData::Post(post) => Some(post),
-            _ => None,
-        }
-    }
-    
-    /// Try to get this Thing as a Comment
-    pub fn as_comment(&self) -> Option<&RedditComment> {
-        if self.kind != "t1" {
-            return None;
-        }
-        match &self.data {
-            RedditThingData::Comment(comment) => Some(comment),
-            _ => None,
-        }
-    }
-    
-    /// Check if this is a "more comments" indicator
-    pub fn is_more_comments(&self) -> bool {
-        self.kind == "more"
-    }
-}
-
-// Helper methods for working with listing responses
-impl RedditListingResponse {
-    /// Extract all posts from this listing
-    pub fn get_posts(&self) -> Vec<&RedditPost> {
-        self.data.children.iter()
-            .filter_map(|thing| thing.as_post())
-            .collect()
-    }
-    
-    /// Extract all comments from this listing
-    pub fn get_comments(&self) -> Vec<&RedditComment> {
-        self.data.children.iter()
-            .filter_map(|thing| thing.as_comment())
-            .collect()
-    }
-    
-    /// Extract posts as owned values
-    pub fn into_posts(self) -> Vec {
-        self.data.children.into_iter()
-            .filter_map(|thing| {
-                if thing.kind == "t3" {
-                    match thing.data {
-                        RedditThingData::Post(post) => Some(post),
-                        _ => None,
-                    }
-                } else {
-                    None
-                }
-            })
-            .collect()
-    }
-    
-    /// Extract comments as owned values
-    pub fn into_comments(self) -> Vec {
-        self.data.children.into_iter()
-            .filter_map(|thing| {
-                if thing.kind == "t1" {
-                    match thing.data {
-                        RedditThingData::Comment(comment) => Some(comment),
-                        _ => None,
-                    }
-                } else {
-                    None
-                }
-            })
-            .collect()
-    }
-}
-
-
-
diff --git a/reducer.rs b/reducer.rs
deleted file mode 100644
index 1ba8030feaf783b066b2e0bba4d2d05659c992f4..0000000000000000000000000000000000000000
--- a/reducer.rs
+++ /dev/null
@@ -1,845 +0,0 @@
-use std::collections::{HashMap, HashSet, VecDeque};
-
-use serde::{Deserialize, Serialize};
-
-use crate::canonical_path::canonicalize_tag;
-use crate::dsl;
-use crate::events::{Event, Ingest, ThreadCapability};
-use crate::path_types::ItemId;
-
-#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
-pub enum ScopeId {
-    Public,
-    Room(String),
-}
-
-/// Wire `room` field → content scope (`"public"` → [`ScopeId::Public`]).
-pub fn scope_from_room_wire(room: &str) -> ScopeId {
-    let r = room.trim();
-    if r.is_empty() || r == "public" {
-        ScopeId::Public
-    } else {
-        ScopeId::Room(r.to_string())
-    }
-}
-
-/// Parsed vote data (internal representation).
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct VoteData {
-    pub ts: i64,
-    pub a: ItemId,
-    pub b: ItemId,
-    pub ratio_left: i32,
-    pub ratio_right: i32,
-    pub body: String,
-    pub principal: String,
-    pub delegate: Option,
-    /// Forum channel where this vote was cast (tag only, not room id).
-    pub thread_tag: String,
-}
-
-#[derive(Debug, Clone)]
-pub struct GroupState {
-    pub item_to_idx: HashMap,
-    pub idx_to_item: Vec,
-
-    /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.
-    pub edges: HashMap<(usize, usize), f64>,
-
-    /// Unordered pairs that have at least one vote recorded between them (i,
-    pub dirty: bool,
-    pub cached_scores: Vec,
-    pub recent_votes: VecDeque,
-}
-
-impl Default for GroupState {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-impl GroupState {
-    pub fn new() -> Self {
-        Self {
-            item_to_idx: HashMap::new(),
-            idx_to_item: Vec::new(),
-            edges: HashMap::new(),
-            voted_pairs: HashSet::new(),
-            dirty: true,
-            cached_scores: Vec::new(),
-            recent_votes: VecDeque::with_capacity(200),
-        }
-    }
-
-    fn ensure_item(&mut self, item: &ItemId) -> usize {
-        if let Some(&idx) = self.item_to_idx.get(item) {
-            return idx;
-        }
-        let idx = self.idx_to_item.len();
-        self.idx_to_item.push(item.clone());
-        self.item_to_idx.insert(item.clone(), idx);
-        self.dirty = true;
-        idx
-    }
-
-    /// Public test helper: insert an item into the group without a vote (for unit tests).
-    pub fn ensure_item_pub(&mut self, item: &str) -> usize {
-        if let Some(canon) = ItemId::parse(item) {
-            self.ensure_item(&canon)
-        } else {
-            // Fallback: treat as raw storage string
-            let canon = ItemId::opaque(item.to_string());
-            self.ensure_item(&canon)
-        }
-    }
-
-    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {
-        if w <= 0.0 {
-            return;
-        }
-        *self.edges.entry((src, dst)).or_insert(0.0) += w;
-        self.dirty = true;
-    }
-
-    pub fn apply_vote(&mut self, mut vote: VoteData) {
-        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());
-        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());
-        vote.thread_tag = canonicalize_tag(&vote.thread_tag);
-        if vote.ratio_left < 0 {
-            vote.ratio_left = 0;
-        }
-        if vote.ratio_right < 0 {
-            vote.ratio_right = 0;
-        }
-        let a_idx = self.ensure_item(&vote.a);
-        let b_idx = self.ensure_item(&vote.b);
-
-        let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };
-        self.voted_pairs.insert((i, j));
-
-        let w_a = vote.ratio_left as f64;
-        let w_b = vote.ratio_right as f64;
-
-        self.add_edge_weight(b_idx, a_idx, w_a);
-        self.add_edge_weight(a_idx, b_idx, w_b);
-
-        self.recent_votes.push_front(vote);
-        while self.recent_votes.len() > 200 {
-            self.recent_votes.pop_back();
-        }
-    }
-}
-
-/// Compact rank-history entry stored per item in the ledger.
-/// `caused_by` is resolved lazily at query time from `ingests_by_id`.
-#[derive(Debug, Clone)]
-pub struct RankHistoryEntry {
-    pub ts: i64,
-    pub scope_rank: usize,
-    pub scope_rank_delta: i32,
-    pub scope_total: usize,
-    pub global_rank: usize,
-    pub global_rank_delta: i32,
-    pub global_total: usize,
-    pub score: f64,
-    pub thread: String,
-    pub post_id: String,
-}
-
-/// Invite link materialized from log events [`crate::events::InviteMinted`] /
-/// [`crate::events::InviteRedeemed`] when those are replayed.
-///
-/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);
-/// they are not appended to the JSONL log, so they disappear on restart. This struct is for
-/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.
-#[derive(Debug, Clone)]
-pub struct ActiveInviteState {
-    pub room_id: String,
-    pub capabilities: HashSet,
-    pub inviter: String,
-    pub uses_remaining: u32,
-    pub expires_ts_ms: Option,
-}
-
-#[derive(Clone, Debug)]
-pub enum RoomTimelineKind {
-    RoomCreated {
-        owner: String,
-        slug: String,
-    },
-    RoomDeleted {
-        deleted_by: String,
-    },
-    GrantAdded {
-        username: String,
-        granted_by: String,
-        capabilities: Vec,
-    },
-    GrantRevoked {
-        username: String,
-        revoked_by: String,
-        capabilities: Vec,
-    },
-}
-
-#[derive(Clone, Debug)]
-pub struct RoomTimelineEntry {
-    pub ts: i64,
-    pub kind: RoomTimelineKind,
-}
-
-#[derive(Debug, Clone)]
-#[derive(Default)]
-pub struct ForumThreadState {
-    pub last_activity_ts: i64,
-    /// Username of the most recent person who bumped this thread.
-    pub last_actor: String,
-}
-
-
-#[derive(Debug, Clone, Default)]
-pub struct ContentState {
-    pub ranking_group: GroupState,
-    pub items: HashSet,
-    pub item_bodies: HashMap,
-    /// Parent [`ItemId`] -> direct children.
-    pub item_children: HashMap>,
-    /// Per-item vote history (most recent first).
-    pub item_votes: HashMap>,
-    /// Per-item ingest references (most recent first).
-    pub item_snippets: HashMap>,
-    /// Item path -> threads that mention or vote on this item.
-    pub item_threads: HashMap>,
-    /// Per-item rank history, oldest first.
-    pub rank_history: HashMap>,
-}
-
-#[derive(Debug, Clone)]
-pub struct ReducerState {
-    pub content: HashMap,
-
-    /// (provider, provider_id) -> username
-    pub users_by_provider: HashMap<(String, String), String>,
-    /// token_id -> (username, salt, token_hash)
-    pub tokens_by_id: HashMap,
-    pub agent_bindings: HashMap,
-
-
-    pub ingests_by_id: HashMap,
-    /// (scope, thread_tag) → ingest ids, newest first.
-    pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,
-    /// Private room ids (`shortid/slug`) known from [`RoomCreated`].
-    pub rooms: HashSet,
-    /// (scope, thread_tag) → last activity.
-    pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,
-    pub actor_last_post_ts: HashMap,
-
-    /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.
-    pub ingests_ordered: Vec,
-    /// Username → ingest ids in post order (oldest first), for profile pages.
-    pub posts_by_actor: HashMap>,
-    /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).
-    pub redacted_posts: HashSet,
-    /// Redaction event timestamp (ms) per post id.
-    pub post_redact_ts: HashMap,
-    /// room_id → username → capabilities
-    pub grants: HashMap>>,
-    /// room_id → chronological room admin lines (for thread UI).
-    pub room_timeline: HashMap>,
-    /// Invite token → active invite (absent when fully consumed or never minted).
-    pub invites: HashMap,
-}
-
-impl ReducerState {
-    pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {
-        self.content.get(scope)
-    }
-
-    /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).
-    pub fn try_thread_post_index_chronological(
-        &self,
-        scope: &ScopeId,
-        thread_tag: &str,
-        post_id: &str,
-    ) -> Option {
-        let tag = canonicalize_tag(thread_tag);
-        self.ingests_by_scope_thread
-            .get(&(scope.clone(), tag))
-            .and_then(|q| q.iter().rev().position(|pid| pid == post_id))
-    }
-
-    pub fn thread_post_index_chronological(
-        &self,
-        scope: &ScopeId,
-        thread_tag: &str,
-        post_id: &str,
-    ) -> usize {
-        self.try_thread_post_index_chronological(scope, thread_tag, post_id)
-            .expect(
-                "post_id must appear in ingests_by_scope_thread for this scope and thread",
-            )
-    }
-
-    /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.
-    pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {
-        self.posts_by_actor
-            .get(actor)
-            .map(|q| q.iter().cloned().collect())
-            .unwrap_or_default()
-    }
-
-    /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.
-    pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {
-        self.posts_by_actor_ids(actor)
-            .into_iter()
-            .filter(|id| {
-                !self.redacted_posts.contains(id)
-                    && self.ingests_by_id.get(id).is_some_and(|ing| {
-                        let scope = scope_from_room_wire(&ing.room_id);
-                        match &scope {
-                            ScopeId::Public => true,
-                            ScopeId::Room(rid) => {
-                                viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))
-                            }
-                        }
-                    })
-            })
-            .collect()
-    }
-
-    pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {
-        self.grants
-            .get(room_id)
-            .and_then(|t| t.get(username))
-            .map(|caps| caps.contains(&cap))
-            .unwrap_or(false)
-    }
-
-    /// Invite link is present, not expired, and has uses left.
-    pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {
-        let inv = self.invites.get(token)?;
-        if inv.uses_remaining == 0 {
-            return None;
-        }
-        if let Some(exp) = inv.expires_ts_ms {
-            if now_ms > exp {
-                return None;
-            }
-        }
-        Some(inv)
-    }
-
-    pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {
-        self.content.entry(scope).or_default()
-    }
-
-    pub fn public(&self) -> &ContentState {
-        self.content.get(&ScopeId::Public).expect("public scope missing")
-    }
-
-    /// Register parent→child edges for the full ancestor chain.
-    /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `""→a`.
-    /// Stops early when an intermediate is already registered (its ancestors must be too).
-    /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4
-    fn add_child_edge(content: &mut ContentState, item: &ItemId) {
-        let mut child = item.clone();
-        loop {
-            let Some(parent) = child.parent() else { break };
-            let is_new = content
-                .item_children
-                .entry(parent.clone())
-                .or_default()
-                .insert(child);
-            if !is_new { break; }
-            child = parent;
-        }
-    }
-
-    /// Resolve an item path as a first-class [`ItemId`].
-    fn normalize_item(item: &str) -> Option {
-        ItemId::parse(item)
-    }
-
-    /// 1-indexed rank of `item` within its connected component in the parent scope.
-    /// 0 if the item has no votes connecting it to siblings (unranked).
-    fn scope_rank_of(
-        group: &GroupState,
-        item: &ItemId,
-        item_children: &HashMap>,
-    ) -> usize {
-        let scope = match item.parent() {
-            Some(p) => p,
-            None => return 0,
-        };
-        let children = match item_children.get(&scope) {
-            None => return 0,
-            Some(c) => c,
-        };
-        let &item_global_idx = match group.item_to_idx.get(item) {
-            None => return 0,
-            Some(i) => i,
-        };
-        // Map scope children to compact local indices.
-        let sibling_idxs: Vec = children.iter()
-            .filter_map(|c| group.item_to_idx.get(c).copied())
-            .collect();
-        let global_to_local: HashMap = sibling_idxs.iter()
-            .enumerate().map(|(l, &g)| (g, l)).collect();
-        let item_local = match global_to_local.get(&item_global_idx) {
-            None => return 0,
-            Some(&l) => l,
-        };
-        // Connected components within scope.
-        let (comps, _) = crate::ranking::connected_components_from_voted_pairs(
-            sibling_idxs.len(),
-            group.voted_pairs.iter().filter_map(|(a, b)| {
-                Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))
-            }),
-        );
-        // Find the component containing this item.
-        let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {
-            None => return 0,
-            Some(c) => c,
-        };
-        let comp_global: Vec = comp_local.iter()
-            .filter_map(|&l| sibling_idxs.get(l).copied())
-            .collect();
-        let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);
-        ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)
-    }
-
-    /// 1-indexed position of `item` in the component-aware global flat list.
-    /// Components sorted largest-first; items ranked within each component.
-    /// 0 if the item is not in the ranking group.
-    fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {
-        if !group.item_to_idx.contains_key(item) {
-            return 0;
-        }
-        let n = group.idx_to_item.len();
-        let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(
-            n, group.voted_pairs.iter().copied(),
-        );
-        comps.sort_by_key(|b| std::cmp::Reverse(b.len()));
-        let mut pos = 1usize;
-        for comp in &comps {
-            let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);
-            for r in &ranked {
-                if &r.item == item {
-                    return pos;
-                }
-                pos += 1;
-            }
-        }
-        0
-    }
-
-    /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).
-    fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {
-        let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;
-        let canonical_thread = canonicalize_tag(&ing.thread_tag);
-
-        let voted_items: Vec = doc
-            .statements
-            .iter()
-            .filter_map(|s| {
-                if let dsl::Stmt::Vote { item1, item2, .. } = s {
-                    Some([item1, item2])
-                } else {
-                    None
-                }
-            })
-            .flat_map(|pair| pair.into_iter())
-            .filter_map(|raw| Self::normalize_item(raw))
-            .collect::>()
-            .into_iter()
-            .collect();
-
-        let principal = ing.principal.clone();
-        let delegate = ing.delegate.clone();
-
-        let before: HashMap = if !voted_items.is_empty() {
-            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);
-            voted_items
-                .iter()
-                .map(|it| {
-                    (
-                        it.clone(),
-                        (
-                            Self::scope_rank_of(&content.ranking_group, it, &content.item_children),
-                            Self::global_rank_of(&content.ranking_group, it),
-                        ),
-                    )
-                })
-                .collect()
-        } else {
-            HashMap::new()
-        };
-
-        let mut ingest_items: HashSet = HashSet::new();
-
-        for stmt in doc.statements {
-            match stmt {
-                dsl::Stmt::Item { title, body } => {
-                    let Some(item) = Self::normalize_item(&title) else {
-                        continue;
-                    };
-                    nav!(content.items, set_elem(item.clone()));
-                    ingest_items.insert(item.clone());
-                    Self::add_child_edge(content, &item);
-
-                    if let Some(body_text) = body {
-                        if !body_text.trim().is_empty() {
-                            nav!(content.item_bodies, keypath(item.clone()), setval(body_text));
-                        }
-                    }
-                }
-                dsl::Stmt::Vote {
-                    item1,
-                    item2,
-                    ratio_left,
-                    ratio_right,
-                    explanation,
-                } => {
-                    let Some(item_a) = Self::normalize_item(&item1) else {
-                        continue;
-                    };
-                    let Some(item_b) = Self::normalize_item(&item2) else {
-                        continue;
-                    };
-
-                    let vote = VoteData {
-                        ts: ing.ts,
-                        a: item_a.clone(),
-                        b: item_b.clone(),
-                        ratio_left,
-                        ratio_right,
-                        body: explanation,
-                        principal: principal.clone(),
-                        delegate: delegate.clone(),
-                        thread_tag: canonical_thread.clone(),
-                    };
-
-                    ingest_items.insert(item_a.clone());
-                    ingest_items.insert(item_b.clone());
-
-                    nav!(content.items, set_elem(item_a.clone()));
-                    nav!(content.items, set_elem(item_b.clone()));
-                    Self::add_child_edge(content, &item_a);
-                    Self::add_child_edge(content, &item_b);
-
-                    content.ranking_group.apply_vote(vote.clone());
-
-                    for it in [&item_a, &item_b] {
-                        nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));
-                    }
-                }
-                dsl::Stmt::Prose { .. } => {}
-            }
-        }
-
-        for item in ingest_items.iter() {
-            nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));
-        }
-
-        for item in ingest_items.iter() {
-            nav!(
-                content.item_threads,
-                keypath(item.clone()),
-                set_elem(canonical_thread.clone())
-            );
-        }
-
-        if !voted_items.is_empty() {
-            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);
-            let thread = canonical_thread.clone();
-            for item in &voted_items {
-                let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);
-                let after_global = Self::global_rank_of(&content.ranking_group, item);
-                let score = content
-                    .ranking_group
-                    .item_to_idx
-                    .get(item)
-                    .and_then(|&i| content.ranking_group.cached_scores.get(i))
-                    .copied()
-                    .unwrap_or(0.0);
-                let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));
-                let prev = content.rank_history.get(item).and_then(|v| v.last());
-                let scope_delta = if prev.is_none() {
-                    0
-                } else {
-                    after_scope as i32 - before_scope as i32
-                };
-                let global_delta = if prev.is_none() {
-                    0
-                } else {
-                    after_global as i32 - before_global as i32
-                };
-                let scope_total = item
-                    .parent()
-                    .and_then(|p| content.item_children.get(&p))
-                    .map(|s| s.len())
-                    .unwrap_or(0);
-                let global_total = content.ranking_group.idx_to_item.len();
-                content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {
-                    ts: ing.ts,
-                    scope_rank: after_scope,
-                    scope_rank_delta: scope_delta,
-                    scope_total,
-                    global_rank: after_global,
-                    global_rank_delta: global_delta,
-                    global_total,
-                    score,
-                    thread: thread.clone(),
-                    post_id: ing.id.clone(),
-                });
-            }
-        }
-
-        Ok(())
-    }
-
-    fn rebuild_scope_content(&mut self, scope: ScopeId) {
-        let mut cs = ContentState::default();
-        for id in &self.ingests_ordered {
-            if self.redacted_posts.contains(id) {
-                continue;
-            }
-            let Some(ing) = self.ingests_by_id.get(id) else {
-                continue;
-            };
-            if scope_from_room_wire(&ing.room_id) != scope {
-                continue;
-            }
-            let _ = Self::apply_ingest_to_content(&mut cs, ing);
-        }
-        self.content.insert(scope, cs);
-        // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so
-        // per-post URLs and chronological indices remain stable; only projected garden state resets.
-    }
-
-    /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).
-    fn purge_private_room(&mut self, room_id: &str) {
-        let scope = ScopeId::Room(room_id.to_string());
-        self.rooms.remove(room_id);
-        self.grants.remove(room_id);
-        self.room_timeline.remove(room_id);
-        self.invites.retain(|_, inv| inv.room_id != room_id);
-        self.content.remove(&scope);
-        self.forum_threads.retain(|(s, _), _| s != &scope);
-        self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);
-
-        let mut to_drop: Vec = self
-            .ingests_by_id
-            .iter()
-            .filter(|(_, ing)| ing.room_id.trim() == room_id)
-            .map(|(id, _)| id.clone())
-            .collect();
-        to_drop.sort();
-        to_drop.dedup();
-        for id in to_drop {
-            if let Some(ing) = self.ingests_by_id.remove(&id) {
-                self.posts_by_actor
-                    .entry(ing.principal)
-                    .and_modify(|q| {
-                        q.retain(|x| x != &id);
-                    });
-            }
-            self.ingests_ordered.retain(|x| x != &id);
-            self.redacted_posts.remove(&id);
-            self.post_redact_ts.remove(&id);
-        }
-    }
-
-    pub fn apply_event(&mut self, event: Event) {
-        match event {
-            Event::UserRegistered(ur) => {
-                self.users_by_provider.insert(
-                    (ur.provider.to_lowercase(), ur.provider_id.clone()),
-                    ur.username,
-                );
-            }
-            Event::TokenIssued(ti) => {
-                self.tokens_by_id.insert(
-                    ti.token_id.clone(),
-                    (ti.username, ti.salt.clone(), ti.token_hash.clone()),
-                );
-            }
-            Event::AgentBound(ab) => {
-                if ab.agent.is_empty() {
-                    return;
-                }
-                self.agent_bindings.insert(ab.agent, ab.username);
-            }
-            Event::RoomCreated(rc) => {
-                self.rooms.insert(rc.room_id.clone());
-                self.room_timeline
-                    .entry(rc.room_id.clone())
-                    .or_default()
-                    .push(RoomTimelineEntry {
-                        ts: rc.ts,
-                        kind: RoomTimelineKind::RoomCreated {
-                            owner: rc.owner.clone(),
-                            slug: rc.slug.clone(),
-                        },
-                    });
-            }
-            Event::RoomDeleted(rd) => {
-                let room_id = rd.room_id.clone();
-                if self.rooms.contains(&room_id) {
-                    self.room_timeline
-                        .entry(room_id.clone())
-                        .or_default()
-                        .push(RoomTimelineEntry {
-                            ts: rd.ts,
-                            kind: RoomTimelineKind::RoomDeleted {
-                                deleted_by: rd.deleted_by.clone(),
-                            },
-                        });
-                }
-                self.purge_private_room(&room_id);
-            }
-            Event::Ingest(mut ing) => {
-                ing.thread_tag = canonicalize_tag(&ing.thread_tag);
-                let room_key = ing.room_id.trim().to_string();
-                let scope = scope_from_room_wire(&room_key);
-                let canonical_thread = ing.thread_tag.clone();
-                let scope_thread_key = (scope.clone(), canonical_thread.clone());
-
-                {
-                    let content = self.content_for_scope_mut(scope.clone());
-                    if Self::apply_ingest_to_content(content, &ing).is_err() {
-                        eprintln!(
-                            "WARNING: Skipping malformed ingest event {}: parse failed",
-                            ing.id
-                        );
-                        return;
-                    }
-                }
-
-                self.ingests_by_id.insert(ing.id.clone(), ing.clone());
-
-                let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();
-                let prev_ts = ft.last_activity_ts;
-                if ing.ts > prev_ts {
-                    ft.last_activity_ts = ing.ts;
-                    ft.last_actor = ing.principal.clone();
-                }
-
-                nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));
-
-                nav!(self.ingests_ordered, push_back(ing.id.clone()));
-
-                nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));
-
-                nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));
-            }
-            Event::PostRedacted(pr) => {
-                self.redacted_posts.insert(pr.post_id.clone());
-                self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);
-                let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {
-                    return;
-                };
-                let scope = scope_from_room_wire(ing.room_id.trim());
-                // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.
-                self.rebuild_scope_content(scope);
-            }
-            Event::GrantAdded(ga) => {
-                let room_id = ga.room_id.clone();
-                let caps = self.grants
-                    .entry(ga.room_id)
-                    .or_default()
-                    .entry(ga.username.clone())
-                    .or_default();
-                for cap in ga.capabilities.iter().copied() {
-                    caps.insert(cap);
-                }
-                self.room_timeline
-                    .entry(room_id)
-                    .or_default()
-                    .push(RoomTimelineEntry {
-                        ts: ga.ts,
-                        kind: RoomTimelineKind::GrantAdded {
-                            username: ga.username.clone(),
-                            granted_by: ga.granted_by.clone(),
-                            capabilities: ga.capabilities.clone(),
-                        },
-                    });
-            }
-            Event::GrantRevoked(gr) => {
-                let room_id = gr.room_id.clone();
-                if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {
-                    let username = gr.username.clone();
-                    if let Some(caps) = room_grants.get_mut(&username) {
-                        for cap in &gr.capabilities {
-                            caps.remove(cap);
-                        }
-                        if caps.is_empty() {
-                            room_grants.remove(&username);
-                        }
-                    }
-                    if room_grants.is_empty() {
-                        self.grants.remove(&gr.room_id);
-                    }
-                }
-                self.room_timeline
-                    .entry(room_id)
-                    .or_default()
-                    .push(RoomTimelineEntry {
-                        ts: gr.ts,
-                        kind: RoomTimelineKind::GrantRevoked {
-                            username: gr.username.clone(),
-                            revoked_by: gr.revoked_by.clone(),
-                            capabilities: gr.capabilities.clone(),
-                        },
-                    });
-            }
-            Event::InviteMinted(im) => {
-                self.invites.insert(
-                    im.token.clone(),
-                    ActiveInviteState {
-                        room_id: im.room_id.clone(),
-                        capabilities: im.capabilities.iter().copied().collect(),
-                        inviter: im.inviter.clone(),
-                        uses_remaining: im.max_uses,
-                        expires_ts_ms: im.expires_ts_ms,
-                    },
-                );
-            }
-            Event::InviteRedeemed(ir) => {
-                if let Some(inv) = self.invites.get_mut(&ir.token) {
-                    inv.uses_remaining = inv.uses_remaining.saturating_sub(1);
-                    if inv.uses_remaining == 0 {
-                        self.invites.remove(&ir.token);
-                    }
-                }
-            }
-        }
-    }
-}
-
-impl Default for ReducerState {
-    fn default() -> Self {
-        let mut content = HashMap::new();
-        content.insert(ScopeId::Public, ContentState::default());
-        Self {
-            content,
-            users_by_provider: HashMap::new(),
-            tokens_by_id: HashMap::new(),
-            agent_bindings: HashMap::new(),
-            ingests_by_id: HashMap::new(),
-            ingests_by_scope_thread: HashMap::new(),
-            rooms: HashSet::new(),
-            forum_threads: HashMap::new(),
-            actor_last_post_ts: HashMap::new(),
-            ingests_ordered: Vec::new(),
-            posts_by_actor: HashMap::new(),
-            redacted_posts: HashSet::new(),
-            post_redact_ts: HashMap::new(),
-            grants: HashMap::new(),
-            room_timeline: HashMap::new(),
-            invites: HashMap::new(),
-        }
-    }
-}
-
diff --git a/scripts/clj-test.sh b/scripts/clj-test.sh
new file mode 100755
index 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952
--- /dev/null
+++ b/scripts/clj-test.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mkdir -p target
+exec clojure -M:kaocha
diff --git a/seed.tdsl b/seed.tdsl
deleted file mode 100644
index 0b707179f09781c53d6526cffeb9158579698ad7..0000000000000000000000000000000000000000
--- a/seed.tdsl
+++ /dev/null
@@ -1,23 +0,0 @@
-reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social?
-jsonl based. Same stack as slug
-fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval
-Viewcounters for every site
-Login with Reddit oauth?
-Streaks? Streak gates? Progressive revelation?
-One pair of the day
-Use attributes again, for assholery. To distinguish from reddits single dimension
-r/sorter_south_park_characters 
-Is a one shot category that only has the characters.
-subs starting with sorter have special priority in sorter index.
-
-layer above reddit?
-
-
-use websocket based bar i had built in afterlife... 
-
-
-claude {{
-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.
-}}
-
-have index be sorted by viewcount.
\ No newline at end of file
diff --git a/server/Cargo.toml b/server/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..7906a8547d56b8e6a48ef59c37aa82a8510fdee9
--- /dev/null
+++ b/server/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "sorter2-server"
+version = "0.0.1"
+edition = "2021"
+license = "MIT"
+
+[dependencies]
+axum = { version = "0.7", features = ["macros"] }
+axum-extra = { version = "0.9", features = ["cookie"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "fs", "io-util"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "1"
+maud = { version = "0.26", features = ["axum"] }
+tower = "0.5"
+tower-http = { version = "0.5", features = ["trace"] }
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[dev-dependencies]
+reqwest = { version = "0.12", features = ["json"] }
+tempfile = "3"
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..07e2856593f1dd98219b5022d223aca62885292f
--- /dev/null
+++ b/server/src/api/mod.rs
@@ -0,0 +1 @@
+pub mod ui_html;
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..de7cafd8f5eac60eb9675362818ea37531ac6ba9
--- /dev/null
+++ b/server/src/api/ui_html.rs
@@ -0,0 +1,71 @@
+use axum::{
+    extract::State,
+    response::{IntoResponse, Response},
+    Form,
+};
+use std::collections::HashMap;
+
+use crate::{
+    html::{demo_counter_panel, JsBuilder},
+    state::AppState,
+    ui_action::{parse_html_ui_from_form, HtmlUiAction},
+};
+
+fn ui_js_warn(msg: &str) -> Response {
+    let js = format!("console.warn({});", crate::html::js_string_literal(msg));
+    axum::response::Response::builder()
+        .status(axum::http::StatusCode::OK)
+        .header(
+            axum::http::header::CONTENT_TYPE,
+            "text/javascript; charset=utf-8",
+        )
+        .body(axum::body::Body::from(js))
+        .unwrap()
+}
+
+pub async fn post_ui_html(
+    State(state): State,
+    Form(form): Form>,
+) -> impl IntoResponse {
+    let action = match parse_html_ui_from_form(&form) {
+        Ok(a) => a,
+        Err(e) => return ui_js_warn(&e.to_string()).into_response(),
+    };
+
+    match action {
+        HtmlUiAction::BumpDemoCounter => {
+            let count = state.bump_demo_counter().await;
+            let panel = demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref());
+            JsBuilder::new()
+                .morph_selector("#demo-counter-panel", panel)
+                .into_response()
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ui_action::{HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};
+
+    #[test]
+    fn parse_error_is_warn_not_panic() {
+        let form = HashMap::new();
+        let err = parse_html_ui_from_form(&form).unwrap_err();
+        assert!(matches!(err, HtmlUiParseError::MissingRpc));
+    }
+
+    #[test]
+    fn bump_action_deserializes() {
+        let template = serde_json::json!({ "action": "bump_demo_counter" });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        assert_eq!(
+            parse_html_ui_from_form(&form).unwrap(),
+            HtmlUiAction::BumpDemoCounter
+        );
+    }
+}
diff --git a/server/src/event_log.rs b/server/src/event_log.rs
new file mode 100644
index 0000000000000000000000000000000000000000..03c86f7f028cdf167b4943951d724da486b1b82c
--- /dev/null
+++ b/server/src/event_log.rs
@@ -0,0 +1,80 @@
+use std::path::{Path, PathBuf};
+
+use tokio::{
+    fs::{self, OpenOptions},
+    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+};
+
+use crate::events::Event;
+
+#[derive(Debug, thiserror::Error)]
+pub enum EventLogError {
+    #[error("io error: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("json error: {0}")]
+    Json(#[from] serde_json::Error),
+}
+
+#[derive(Debug, Clone)]
+pub struct EventLog {
+    path: PathBuf,
+}
+
+impl EventLog {
+    pub fn new(path: impl Into) -> Self {
+        Self { path: path.into() }
+    }
+
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {
+        if let Some(parent) = self.path.parent() {
+            fs::create_dir_all(parent).await?;
+        }
+        Ok(())
+    }
+
+    pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {
+        self.ensure_parent_dir().await?;
+        let mut f: tokio::fs::File = OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open(&self.path)
+            .await?;
+
+        let mut line = serde_json::to_string(event)?;
+        line.push('\n');
+        f.write_all(line.as_bytes()).await?;
+        f.flush().await?;
+        Ok(())
+    }
+
+    pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {
+        if !fs::try_exists(&self.path).await? {
+            return Ok((vec![], vec![]));
+        }
+
+        let f = fs::File::open(&self.path).await?;
+        let mut reader = BufReader::new(f).lines();
+
+        let mut events = Vec::new();
+        let mut bad_lines = Vec::new();
+
+        let mut line_no: usize = 0;
+        while let Some(line) = reader.next_line().await? {
+            line_no += 1;
+            let trimmed = line.trim();
+            if trimmed.is_empty() {
+                continue;
+            }
+            match serde_json::from_str::(trimmed) {
+                Ok(ev) => events.push(ev),
+                Err(_) => bad_lines.push((line_no, line)),
+            }
+        }
+
+        Ok((events, bad_lines))
+    }
+}
diff --git a/server/src/events.rs b/server/src/events.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0e8d004c9fabe4007ffef45cfcacafb00f4c229d
--- /dev/null
+++ b/server/src/events.rs
@@ -0,0 +1,10 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum Event {
+    /// Page view recorded (path → counter in views.json).
+    ViewRecorded { path: String, ts: i64 },
+    /// Demo counter bump from `POST /ui` (persisted in the single JSONL log).
+    DemoCounterBumped { ts: i64, value: u64 },
+}
diff --git a/server/src/form_template.rs b/server/src/form_template.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b9bc3982a125e94e67c99175ea9055979541abba
--- /dev/null
+++ b/server/src/form_template.rs
@@ -0,0 +1,118 @@
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.
+pub fn template_json_compact(v: &T) -> serde_json::Result {
+    serde_json::to_string(v)
+}
+
+/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
+/// string for `key` (empty if missing). Other keys are unchanged.
+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {
+    match val {
+        Value::Object(map) => {
+            if map.len() == 1 {
+                if let Some(Value::String(field_name)) = map.get("$form") {
+                    let submitted = form_data
+                        .get(field_name.as_str())
+                        .map(|s| s.as_str())
+                        .unwrap_or("");
+                    *val = Value::String(submitted.to_string());
+                    return;
+                }
+            }
+            for v in map.values_mut() {
+                substitute_form_vars(v, form_data);
+            }
+        }
+        Value::Array(arr) => {
+            for v in arr.iter_mut() {
+                substitute_form_vars(v, form_data);
+            }
+        }
+        _ => {}
+    }
+}
+
+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.
+pub fn fill_template_from_form(
+    template_json: &str,
+    form_data: &HashMap,
+) -> Result {
+    let mut v: Value = serde_json::from_str(template_json)?;
+    substitute_form_vars(&mut v, form_data);
+    Ok(v)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde::Deserialize;
+
+    #[derive(Debug, Deserialize, PartialEq, Eq)]
+    struct Demo {
+        room: String,
+        thread_tag: String,
+        nested: Nested,
+    }
+
+    #[derive(Debug, Deserialize, PartialEq, Eq)]
+    struct Nested {
+        text: String,
+    }
+
+    #[test]
+    fn holes_become_strings() {
+        let json = r#"{
+            "room": "public",
+            "thread_tag": {"$form": "tag"},
+            "nested": {"text": {"$form": "body"}}
+        }"#;
+        let mut form = HashMap::new();
+        form.insert("tag".into(), "foo".into());
+        form.insert("body".into(), "hello\nworld".into());
+
+        let v = fill_template_from_form(json, &form).unwrap();
+        let d: Demo = serde_json::from_value(v).unwrap();
+        assert_eq!(
+            d,
+            Demo {
+                room: "public".into(),
+                thread_tag: "foo".into(),
+                nested: Nested {
+                    text: "hello\nworld".into(),
+                },
+            }
+        );
+    }
+
+    #[test]
+    fn missing_form_key_is_empty_string() {
+        let json = r#"{"x": {"$form": "nope"}}"#;
+        let mut form = HashMap::new();
+        form.insert("other".into(), "y".into());
+        let v = fill_template_from_form(json, &form).unwrap();
+        assert_eq!(v["x"], "");
+    }
+
+    #[test]
+    fn array_of_holes() {
+        let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#;
+        let mut form = HashMap::new();
+        form.insert("a".into(), "1".into());
+        form.insert("b".into(), "2".into());
+        let v = fill_template_from_form(json, &form).unwrap();
+        assert_eq!(v["items"], serde_json::json!(["1", "2"]));
+    }
+
+    #[test]
+    fn template_json_compact_escapes_and_single_line() {
+        let s = template_json_compact(&serde_json::json!({
+            "x": "quote\"and\nnewline"
+        }))
+        .unwrap();
+        assert!(!s.contains('\n'));
+        assert!(s.contains("\\\"") || s.contains("\\n"));
+    }
+}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b10773272173b9cc37cbb3983fb2315a7bfed5ea
--- /dev/null
+++ b/server/src/html/mod.rs
@@ -0,0 +1,234 @@
+use axum::{
+    body::Body,
+    extract::{Path, State},
+    http::{header, HeaderValue, StatusCode, Uri},
+    response::{IntoResponse, Response},
+    Form,
+};
+use axum_extra::extract::cookie::CookieJar;
+use maud::{html, Markup, DOCTYPE};
+use serde::Deserialize;
+
+use crate::{
+    form_template::template_json_compact,
+    state::AppState,
+    ui_action::UI_RPC_FIELD,
+};
+
+const THEME_DEFAULT_CSS: &str = include_str!("../../static/theme_default.css");
+const THEME_RETRO_CSS: &str = include_str!("../../static/theme_retro.css");
+const SORTER_UI_JS: &str = include_str!("../../static/sorter_ui.js");
+
+pub const SORTER_THEME_COOKIE: &str = "sorter-theme";
+
+pub fn normalize_theme(raw: &str) -> &'static str {
+    match raw {
+        "retro" => "retro",
+        _ => "default",
+    }
+}
+
+pub fn theme_from_jar(jar: &CookieJar) -> &'static str {
+    jar.get(SORTER_THEME_COOKIE)
+        .map(|c| normalize_theme(c.value()))
+        .unwrap_or("default")
+}
+
+pub fn theme_next_from_uri(uri: &Uri) -> String {
+    uri.path_and_query()
+        .map(|pq| pq.as_str().to_string())
+        .filter(|s| !s.is_empty())
+        .unwrap_or_else(|| "/".to_string())
+}
+
+pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {
+    let t = normalize_theme(theme);
+    let s = format!("{SORTER_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000");
+    HeaderValue::from_str(&s).expect("theme cookie must be ASCII")
+}
+
+fn sanitize_theme_next(next: Option<&str>) -> String {
+    let s = next.unwrap_or("/").trim();
+    if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 {
+        s.to_string()
+    } else {
+        "/".to_string()
+    }
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ThemeForm {
+    theme: String,
+    next: Option,
+}
+
+pub async fn post_theme(Form(form): Form) -> impl IntoResponse {
+    let theme = normalize_theme(&form.theme);
+    let next = sanitize_theme_next(form.next.as_deref());
+    let loc =
+        HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static("/"));
+    Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(header::LOCATION, loc)
+        .header(header::SET_COOKIE, theme_cookie_header_value(theme))
+        .body(Body::empty())
+        .expect("theme redirect response")
+}
+
+pub async fn serve_static(Path(filename): Path) -> impl IntoResponse {
+    if filename == "sorter_ui.js" {
+        return Response::builder()
+            .status(StatusCode::OK)
+            .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+            .header(header::CACHE_CONTROL, "public, max-age=3600")
+            .body(SORTER_UI_JS.to_string())
+            .unwrap()
+            .into_response();
+    }
+
+    let theme = filename
+        .strip_prefix("theme_")
+        .and_then(|s| s.strip_suffix(".css"));
+
+    let css = match theme {
+        Some("default") => THEME_DEFAULT_CSS,
+        Some("retro") => THEME_RETRO_CSS,
+        _ => return (StatusCode::NOT_FOUND, "static file not found").into_response(),
+    };
+
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
+        .header(header::CACHE_CONTROL, "public, max-age=3600")
+        .body(css.to_string())
+        .unwrap()
+        .into_response()
+}
+
+pub(crate) fn js_string_literal(s: &str) -> String {
+    serde_json::to_string(s).expect("javascript string escaping")
+}
+
+pub(crate) struct JsBuilder {
+    snippets: Vec,
+}
+
+impl JsBuilder {
+    pub(crate) fn new() -> Self {
+        Self {
+            snippets: Vec::new(),
+        }
+    }
+
+    pub(crate) fn morph_selector(mut self, selector: &str, markup: Markup) -> Self {
+        let html = js_string_literal(&markup.into_string());
+        self.snippets.push(format!(
+            "var __el = document.querySelector({sel}); if (__el) {{ Idiomorph.morph(__el, {html}); }}",
+            sel = js_string_literal(selector),
+        ));
+        self
+    }
+
+    pub(crate) fn build(self) -> String {
+        self.snippets.join(" ")
+    }
+
+    pub(crate) fn into_response(self) -> Response {
+        Response::builder()
+            .status(StatusCode::OK)
+            .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+            .body(Body::from(self.build()))
+            .unwrap()
+    }
+}
+
+pub fn now_ms() -> i64 {
+    let t = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default();
+    t.as_millis() as i64
+}
+
+fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str) -> Markup {
+    let css_href = format!("/static/theme_{theme}.css");
+    html! {
+        (DOCTYPE)
+        html {
+            head {
+                meta charset="utf-8";
+                meta name="viewport" content="width=device-width, initial-scale=1";
+                title { (title) }
+                link rel="stylesheet" href=(css_href) id="theme-stylesheet";
+                script src="https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js" {}
+            }
+            body class="home" {
+                @if views > 0 {
+                    span class="view-meta muted" { (views) " views" }
+                }
+                div id="errors" {}
+                (body)
+                div id="controls" {
+                    a href="https://github.com/sortersocial/sorter2" id="src-link" { "src" }
+                    form id="sorter-theme-form" method="post" action="/theme" data-navigate="full" {
+                        input type="hidden" name="next" value=(theme_next);
+                        select id="theme-select" name="theme" onchange="this.form.submit()" aria-label="Theme" {
+                            @for (val, label) in [("default", "default"), ("retro", "retro")] {
+                                @if theme == val {
+                                    option value=(val) selected { (label) }
+                                } @else {
+                                    option value=(val) { (label) }
+                                }
+                            }
+                        }
+                    }
+                }
+                script src="/static/sorter_ui.js" {}
+            }
+        }
+    }
+}
+
+pub fn demo_counter_panel(count: u64, event_log_path: &str) -> Markup {
+    let rpc = template_json_compact(&serde_json::json!({ "action": "bump_demo_counter" }))
+        .expect("rpc json");
+    html! {
+        section id="demo-counter-panel" class="demo-panel" {
+            h1 { "sorter2" }
+            p class="muted" {
+                "Pairwise ranking over Reddit — seed scaffold. "
+                "Click bumps a counter persisted to the JSONL event log."
+            }
+            p class="demo-count" {
+                strong { "Counter: " }
+                span id="demo-count-value" { (count) }
+            }
+            p class="muted small" {
+                "Event log: " code { (event_log_path) }
+            }
+            form method="post" action="/ui" id="demo-bump-form" {
+                input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
+                button type="submit" class="btn-primary" { "Bump (POST /ui → eval JS)" }
+            }
+            p class="muted small" {
+                "Uses hidden "
+                code { "__rpc__" }
+                " JSON + Idiomorph morph — no full page reload."
+            }
+        }
+    }
+}
+
+pub async fn home(
+    State(state): State,
+    jar: CookieJar,
+    uri: Uri,
+) -> impl IntoResponse {
+    let path = uri.path().to_string();
+    state.views.increment(path.clone());
+    let views = state.views.get_views(&path);
+    let count = *state.demo_counter.read().await;
+    let theme = theme_from_jar(&jar);
+    let theme_next = theme_next_from_uri(&uri);
+    let body = demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref());
+    layout("sorter2", body, views, theme, &theme_next)
+}
diff --git a/server/src/lib.rs b/server/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..19976e549168e5305c96f490ec31e6d705a54ba2
--- /dev/null
+++ b/server/src/lib.rs
@@ -0,0 +1,53 @@
+pub mod api;
+pub mod event_log;
+pub mod events;
+pub mod form_template;
+pub mod html;
+pub mod path_types;
+pub mod ranking;
+pub mod reducer;
+pub mod state;
+pub mod ui_action;
+pub mod views;
+
+use axum::{
+    routing::{get, post},
+    Router,
+};
+use tower_http::trace::TraceLayer;
+
+use crate::state::{AppConfig, AppState};
+
+pub async fn create_app_state(cfg: AppConfig) -> AppState {
+    AppState::new(cfg).await
+}
+
+pub fn create_app(state: AppState) -> Router {
+    Router::new()
+        .route("/healthz", get(|| async { "ok" }))
+        .route("/static/:filename", get(crate::html::serve_static))
+        .route("/", get(crate::html::home))
+        .route("/ui", post(crate::api::ui_html::post_ui_html))
+        .route("/theme", post(crate::html::post_theme))
+        .with_state(state)
+        .layer(TraceLayer::new_for_http())
+}
+
+pub async fn run(cfg: AppConfig) -> Result<(), Box> {
+    let state = create_app_state(cfg.clone()).await;
+    let app = create_app(state);
+
+    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], cfg.port));
+    tracing::info!("listening on http://{addr}");
+    let listener = tokio::net::TcpListener::bind(addr).await?;
+    axum::serve(listener, app)
+        .with_graceful_shutdown(shutdown_signal())
+        .await?;
+    Ok(())
+}
+
+async fn shutdown_signal() {
+    tokio::signal::ctrl_c()
+        .await
+        .expect("failed to install CTRL+C handler");
+}
diff --git a/server/src/main.rs b/server/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c22ec6c9f5358e5ec99fb83210dc351938505a93
--- /dev/null
+++ b/server/src/main.rs
@@ -0,0 +1,15 @@
+use sorter2_server::state::AppConfig;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            tracing_subscriber::EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| "sorter2_server=info,tower_http=info".into()),
+        )
+        .init();
+
+    let cfg = AppConfig::from_env();
+    std::fs::create_dir_all(&cfg.data_dir)?;
+    sorter2_server::run(cfg).await
+}
diff --git a/server/src/path_types.rs b/server/src/path_types.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1cdc96a25b1954f11aaf955203e2b9907b578366
--- /dev/null
+++ b/server/src/path_types.rs
@@ -0,0 +1,30 @@
+use serde::{Deserialize, Serialize};
+use std::fmt;
+
+/// Stable item key for votes and rankings (opaque string for now).
+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct ItemId(String);
+
+impl ItemId {
+    pub fn parse(s: &str) -> Option {
+        let t = s.trim();
+        if t.is_empty() {
+            return None;
+        }
+        Some(Self(t.to_string()))
+    }
+
+    pub fn opaque(s: impl Into) -> Self {
+        Self(s.into())
+    }
+
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+impl fmt::Display for ItemId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.0)
+    }
+}
diff --git a/server/src/ranking.rs b/server/src/ranking.rs
new file mode 100644
index 0000000000000000000000000000000000000000..89d3280126a8d8f841721ce8cb63ff735d68752a
--- /dev/null
+++ b/server/src/ranking.rs
@@ -0,0 +1,388 @@
+use std::collections::{HashMap, HashSet};
+
+use crate::path_types::ItemId;
+use crate::reducer::GroupState;
+
+#[derive(Debug, Clone)]
+pub struct RankedItem {
+    pub item: ItemId,
+    pub score: f64,
+}
+
+/// Compute connected components over the voted-pairs graph (treated as undirected).
+///
+/// Returns:
+/// - `components`: each component is a sorted list of node indices, excluding isolates.
+/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).
+pub fn connected_components_from_voted_pairs(
+    n: usize,
+    voted_pairs: impl Iterator,
+) -> (Vec>, Vec) {
+    let mut adj: Vec> = vec![Vec::new(); n];
+    for (a, b) in voted_pairs {
+        if a >= n || b >= n || a == b {
+            continue;
+        }
+        adj[a].push(b);
+        adj[b].push(a);
+    }
+
+    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();
+    isolates.sort();
+
+    let mut seen = vec![false; n];
+    for &i in &isolates {
+        seen[i] = true;
+    }
+
+    let mut comps: Vec> = Vec::new();
+    for i in 0..n {
+        if seen[i] {
+            continue;
+        }
+        let mut stack = vec![i];
+        seen[i] = true;
+        let mut comp: Vec = Vec::new();
+        while let Some(x) = stack.pop() {
+            comp.push(x);
+            for &y in &adj[x] {
+                if !seen[y] {
+                    seen[y] = true;
+                    stack.push(y);
+                }
+            }
+        }
+        comp.sort();
+        comps.push(comp);
+    }
+
+    (comps, isolates)
+}
+
+/// Compute rank centrality scores for a GroupState.
+/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing
+/// an O(E) multiply per iteration.
+pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {
+    if !group.dirty && !group.cached_scores.is_empty() {
+        return;
+    }
+
+    let n = group.idx_to_item.len();
+    if n == 0 {
+        group.cached_scores = vec![];
+        group.dirty = false;
+        return;
+    }
+    if n == 1 {
+        group.cached_scores = vec![1.0];
+        group.dirty = false;
+        return;
+    }
+
+    let scores = compute_scores_from_edges(
+        n,
+        group.edges.iter().map(|(&k, &w)| (k, w)),
+        max_iters,
+        tol,
+    );
+    group.cached_scores = scores;
+    group.dirty = false;
+}
+
+pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {
+    compute_group_ranking(group, max_iters, tol);
+    let mut items: Vec = group
+        .idx_to_item
+        .iter()
+        .enumerate()
+        .map(|(i, item)| RankedItem {
+            item: item.clone(),
+            score: *group.cached_scores.get(i).unwrap_or(&0.0),
+        })
+        .collect();
+
+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
+    items
+}
+
+fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {
+    if n == 0 {
+        return vec![];
+    }
+    if n == 1 {
+        return vec![1.0];
+    }
+
+    // Collect raw edges into a map for pairwise normalization.
+    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();
+    for ((src, dst), w) in edges {
+        if src >= n || dst >= n || w <= 0.0 {
+            continue;
+        }
+        *raw.entry((src, dst)).or_insert(0.0) += w;
+    }
+
+    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).
+    // This ensures repeated votes on the same pair don't inflate influence
+    // beyond what the ratio implies.
+    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();
+    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();
+    for (i, j) in keys {
+        if normalized.contains_key(&(i, j)) {
+            continue;
+        }
+        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);
+        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);
+        let total = w_ij + w_ji;
+        if total <= 0.0 {
+            continue;
+        }
+        normalized.insert((i, j), w_ij / total);
+        if w_ji > 0.0 {
+            normalized.insert((j, i), w_ji / total);
+        }
+    }
+
+    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):
+    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared
+    //   P_ii = 1 - (1/d_max) * Σ_k A_ik
+    // where d_i is the *degree* (number of distinct neighbors compared) and
+    // d_max = max_i d_i. Using the unweighted degree — not the sum of
+    // pairwise-normalized weights — is what guarantees aperiodicity: it
+    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree
+    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous
+    // loss). Without this, regular comparison graphs (e.g. a pure star at
+    // ratio 2:1) produce a bipartite chain that oscillates instead of
+    // converging — see issue #146.
+    let mut out_edges: Vec> = vec![Vec::new(); n];
+    let mut neighbors: Vec> = vec![HashSet::new(); n];
+
+    for ((src, dst), w) in &normalized {
+        out_edges[*src].push((*dst, *w));
+        neighbors[*src].insert(*dst);
+        neighbors[*dst].insert(*src);
+    }
+
+    let weight_sum: Vec = out_edges
+        .iter()
+        .map(|es| es.iter().map(|(_, w)| *w).sum())
+        .collect();
+    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);
+    if d_max == 0 {
+        return vec![1.0 / n as f64; n];
+    }
+    let d_max_f = d_max as f64;
+
+    let mut scores = vec![1.0 / n as f64; n];
+    let mut next = vec![0.0f64; n];
+
+    for _ in 0..max_iters {
+        next.fill(0.0);
+        for i in 0..n {
+            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;
+            next[i] += scores[i] * stay_prob;
+
+            if out_edges[i].is_empty() {
+                continue;
+            }
+            for &(dst, w) in &out_edges[i] {
+                next[dst] += scores[i] * (w / d_max_f);
+            }
+        }
+
+        let diff: f64 = scores
+            .iter()
+            .zip(next.iter())
+            .map(|(a, b)| (a - b).abs())
+            .sum();
+
+        scores.clone_from_slice(&next);
+        if diff < tol {
+            break;
+        }
+    }
+
+    let sum: f64 = scores.iter().sum();
+    if sum.is_finite() && sum > 0.0 {
+        for s in &mut scores {
+            *s /= sum;
+        }
+    }
+    scores
+}
+
+/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.
+///
+/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.
+pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {
+    if idxs.is_empty() {
+        return vec![];
+    }
+
+    // Map original idx -> compact idx [0..m)
+    let mut map: HashMap = HashMap::with_capacity(idxs.len());
+    for (j, &i) in idxs.iter().enumerate() {
+        map.insert(i, j);
+    }
+
+    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {
+        let s = *map.get(&src)?;
+        let d = *map.get(&dst)?;
+        Some(((s, d), w))
+    });
+
+    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);
+
+    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).
+    let mut items: Vec = idxs
+        .iter()
+        .enumerate()
+        .filter_map(|(j, &orig)| {
+            let item = group.idx_to_item.get(orig)?.clone();
+            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })
+        })
+        .collect();
+
+    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
+    items
+}
+
+pub fn group_summary_scores(
+    group: &mut GroupState,
+    max_iters: usize,
+    tol: f64,
+) -> HashMap {
+    ranked_items(group, max_iters, tol)
+        .into_iter()
+        .map(|r| (r.item, r.score))
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::reducer::VoteData;
+
+    fn mk_group() -> GroupState {
+        GroupState::new()
+    }
+
+    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {
+        use crate::path_types::ItemId;
+        VoteData {
+            ts,
+            a: ItemId::parse(a).unwrap(),
+            b: ItemId::parse(b).unwrap(),
+            ratio_left: l,
+            ratio_right: r,
+            body: "because".to_string(),
+            principal: "test".to_string(),
+            delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
+            thread_tag: "untagged".to_string(),
+        }
+    }
+
+    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).
+    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the
+    /// chain was bipartite; power iteration oscillated and returned the
+    /// uniform initial distribution after an even number of steps. Using the
+    /// paper's degree-based d_max gives every node a positive self-loop and
+    /// the chain converges to the correct stationary distribution.
+    #[test]
+    fn star_topology_winner_at_top_via_subset() {
+        let mut g = mk_group();
+        g.apply_vote(vote(1, "zebra", "alpha", 2, 1));
+        g.apply_vote(vote(2, "zebra", "beta", 2, 1));
+
+        let mut items: Vec<(usize, String)> = g
+            .idx_to_item
+            .iter()
+            .enumerate()
+            .map(|(i, it)| (i, it.as_str().to_string()))
+            .collect();
+        items.sort_by(|a, b| a.1.cmp(&b.1));
+        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();
+
+        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);
+        for r in &ranked {
+            eprintln!("{}: {}", r.item.as_str(), r.score);
+        }
+        assert_eq!(
+            ranked[0].item.as_str(),
+            "zebra",
+            "zebra won both votes and should rank #1"
+        );
+    }
+
+    #[test]
+    fn group_ranking_cache_dirty_flow() {
+        let mut g = mk_group();
+        assert!(g.dirty);
+
+        g.apply_vote(vote(1, "a", "b", 3, 1));
+        assert!(g.dirty);
+        assert!(g.cached_scores.is_empty());
+
+        compute_group_ranking(&mut g, 10000, 1e-8);
+        assert!(!g.dirty);
+        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());
+
+        // Recomputing when not dirty should be a no-op.
+        let before = g.cached_scores.clone();
+        compute_group_ranking(&mut g, 10000, 1e-8);
+        assert_eq!(before, g.cached_scores);
+    }
+
+    #[test]
+    fn connected_components_split_disconnected_pairs() {
+        let mut g = mk_group();
+        // Two disconnected edges: (a,b) and (c,d)
+        g.apply_vote(vote(1, "a", "b", 3, 1));
+        g.apply_vote(vote(2, "c", "d", 3, 1));
+
+        let n = g.idx_to_item.len();
+        let (mut comps, isolates) =
+            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());
+        assert!(isolates.is_empty());
+        // Order-independent: sort components by their item names for stable assert.
+        comps.sort_by_key(|c| {
+            c.iter()
+                .map(|&i| g.idx_to_item[i].clone())
+                .collect::>()
+        });
+        assert_eq!(comps.len(), 2);
+        let comp0 = comps[0]
+            .iter()
+            .map(|&i| g.idx_to_item[i].as_str())
+            .collect::>();
+        let comp1 = comps[1]
+            .iter()
+            .map(|&i| g.idx_to_item[i].as_str())
+            .collect::>();
+        assert_eq!(comp0, vec!["a", "b"]);
+        assert_eq!(comp1, vec!["c", "d"]);
+    }
+
+    #[test]
+    fn subset_ranking_ranks_within_component_only() {
+        let mut g = mk_group();
+        g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b
+        g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c
+
+        let (comps, _) =
+            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());
+        assert_eq!(comps.len(), 2);
+
+        // Rank each component and ensure winner is first within that component.
+        for comp in comps {
+            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);
+            assert_eq!(ranked.len(), 2);
+            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();
+            if names.contains(&"a") {
+                assert_eq!(names[0], "a");
+            } else {
+                assert_eq!(names[0], "d");
+            }
+        }
+    }
+}
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
new file mode 100644
index 0000000000000000000000000000000000000000..933cfa2043997476ee81f78c3508e885908cfa24
--- /dev/null
+++ b/server/src/reducer.rs
@@ -0,0 +1,94 @@
+use std::collections::{HashMap, HashSet, VecDeque};
+
+use serde::{Deserialize, Serialize};
+
+use crate::path_types::ItemId;
+
+/// Parsed pairwise vote (internal representation).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct VoteData {
+    pub ts: i64,
+    pub a: ItemId,
+    pub b: ItemId,
+    pub ratio_left: i32,
+    pub ratio_right: i32,
+    pub body: String,
+    pub principal: String,
+    pub delegate: Option,
+    pub thread_tag: String,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct GroupState {
+    pub item_to_idx: HashMap,
+    pub idx_to_item: Vec,
+    pub edges: HashMap<(usize, usize), f64>,
+    pub voted_pairs: HashSet<(usize, usize)>,
+    pub dirty: bool,
+    pub cached_scores: Vec,
+    pub recent_votes: VecDeque,
+}
+
+impl GroupState {
+    pub fn new() -> Self {
+        Self {
+            item_to_idx: HashMap::new(),
+            idx_to_item: Vec::new(),
+            edges: HashMap::new(),
+            voted_pairs: HashSet::new(),
+            dirty: true,
+            cached_scores: Vec::new(),
+            recent_votes: VecDeque::with_capacity(200),
+        }
+    }
+
+    fn ensure_item(&mut self, item: &ItemId) -> usize {
+        if let Some(&idx) = self.item_to_idx.get(item) {
+            return idx;
+        }
+        let idx = self.idx_to_item.len();
+        self.idx_to_item.push(item.clone());
+        self.item_to_idx.insert(item.clone(), idx);
+        self.dirty = true;
+        idx
+    }
+
+    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {
+        if w <= 0.0 {
+            return;
+        }
+        *self.edges.entry((src, dst)).or_insert(0.0) += w;
+        self.dirty = true;
+    }
+
+    pub fn apply_vote(&mut self, mut vote: VoteData) {
+        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());
+        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());
+        if vote.ratio_left < 0 {
+            vote.ratio_left = 0;
+        }
+        if vote.ratio_right < 0 {
+            vote.ratio_right = 0;
+        }
+        let a_idx = self.ensure_item(&vote.a);
+        let b_idx = self.ensure_item(&vote.b);
+
+        let (i, j) = if a_idx < b_idx {
+            (a_idx, b_idx)
+        } else {
+            (b_idx, a_idx)
+        };
+        self.voted_pairs.insert((i, j));
+
+        let w_a = vote.ratio_left as f64;
+        let w_b = vote.ratio_right as f64;
+
+        self.add_edge_weight(b_idx, a_idx, w_a);
+        self.add_edge_weight(a_idx, b_idx, w_b);
+
+        self.recent_votes.push_front(vote);
+        while self.recent_votes.len() > 200 {
+            self.recent_votes.pop_back();
+        }
+    }
+}
diff --git a/server/src/state.rs b/server/src/state.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d57a8c2b447312c8079b62e3f55f661ccc5e8447
--- /dev/null
+++ b/server/src/state.rs
@@ -0,0 +1,76 @@
+use std::sync::Arc;
+
+use tokio::sync::RwLock;
+
+use crate::{event_log::EventLog, events::Event, views::ViewStore};
+
+#[derive(Clone)]
+pub struct AppConfig {
+    pub data_dir: String,
+    pub event_log_path: String,
+    pub port: u16,
+}
+
+impl AppConfig {
+    pub fn from_env() -> Self {
+        let data_dir = std::env::var("SORTER2_DATA_DIR").unwrap_or_else(|_| "./data".into());
+        let event_log_path = std::env::var("SORTER2_EVENT_LOG")
+            .unwrap_or_else(|_| format!("{data_dir}/events.jsonl"));
+        let port = std::env::var("PORT")
+            .ok()
+            .and_then(|p| p.parse().ok())
+            .unwrap_or(8080);
+        Self {
+            data_dir,
+            event_log_path,
+            port,
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct AppState {
+    pub cfg: Arc,
+    pub event_log: Arc,
+    pub views: ViewStore,
+    pub demo_counter: Arc>,
+}
+
+impl AppState {
+    pub async fn new(cfg: AppConfig) -> Self {
+        let event_log = Arc::new(EventLog::new(cfg.event_log_path.clone()));
+        let views_path = format!("{}/views.json", cfg.data_dir);
+        let views = ViewStore::new(&views_path);
+
+        let mut demo_counter: u64 = 0;
+        if let Ok((events, _)) = event_log.load_all().await {
+            for ev in events {
+                if let Event::DemoCounterBumped { value, .. } = ev {
+                    demo_counter = demo_counter.max(value);
+                }
+            }
+        }
+
+        Self {
+            cfg: Arc::new(cfg),
+            event_log,
+            views,
+            demo_counter: Arc::new(RwLock::new(demo_counter)),
+        }
+    }
+
+    pub async fn bump_demo_counter(&self) -> u64 {
+        let mut guard = self.demo_counter.write().await;
+        *guard += 1;
+        let value = *guard;
+        drop(guard);
+
+        let ts = crate::html::now_ms();
+        let _ = self
+            .event_log
+            .append(&Event::DemoCounterBumped { ts, value })
+            .await;
+
+        value
+    }
+}
diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs
new file mode 100644
index 0000000000000000000000000000000000000000..68f04d59e24575d69903c0366e1cdb49b3bf903e
--- /dev/null
+++ b/server/src/ui_action.rs
@@ -0,0 +1,55 @@
+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).
+
+use crate::form_template::fill_template_from_form;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use thiserror::Error;
+
+pub const UI_RPC_FIELD: &str = "__rpc__";
+
+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(tag = "action", rename_all = "snake_case")]
+pub enum HtmlUiAction {
+    /// Demo: morph `#demo-counter-panel` after bumping the persisted counter.
+    BumpDemoCounter,
+}
+
+#[derive(Debug, Error)]
+pub enum HtmlUiParseError {
+    #[error("missing __rpc__ field")]
+    MissingRpc,
+    #[error("invalid template json: {0}")]
+    Template(serde_json::Error),
+    #[error("invalid ui action: {0}")]
+    Action(serde_json::Error),
+}
+
+pub fn parse_html_ui_from_form(
+    form: &HashMap,
+) -> Result {
+    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;
+    let mut hole_map = form.clone();
+    hole_map.remove(UI_RPC_FIELD);
+    let v: Value =
+        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;
+    serde_json::from_value(v).map_err(HtmlUiParseError::Action)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn bump_demo_counter_round_trip() {
+        let template = serde_json::json!({ "action": "bump_demo_counter" });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        let a = parse_html_ui_from_form(&form).unwrap();
+        assert_eq!(a, HtmlUiAction::BumpDemoCounter);
+    }
+}
diff --git a/server/src/views.rs b/server/src/views.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c5d94777d7f0bf0249cb0033fd013eb0af773904
--- /dev/null
+++ b/server/src/views.rs
@@ -0,0 +1,62 @@
+use std::{
+    collections::HashMap,
+    sync::{Arc, Mutex},
+};
+use tokio::sync::mpsc;
+
+type CountMap = Arc>>;
+
+#[derive(Clone)]
+pub struct ViewStore {
+    counts: CountMap,
+    flush_tx: mpsc::Sender<()>,
+}
+
+impl ViewStore {
+    pub fn new(json_path: &str) -> Self {
+        let initial: HashMap = std::fs::read_to_string(json_path)
+            .ok()
+            .and_then(|s| serde_json::from_str(&s).ok())
+            .unwrap_or_default();
+
+        let counts: CountMap = Arc::new(Mutex::new(initial));
+        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
+        let path = json_path.to_string();
+
+        let counts_for_writer = counts.clone();
+        tokio::spawn(async move {
+            while flush_rx.recv().await.is_some() {
+                while flush_rx.try_recv().is_ok() {}
+
+                let snapshot: HashMap = {
+                    counts_for_writer.lock().unwrap().clone()
+                };
+
+                let path = path.clone();
+                let _ = tokio::task::spawn_blocking(move || {
+                    if let Ok(json) = serde_json::to_string(&snapshot) {
+                        let tmp = format!("{path}.tmp");
+                        if std::fs::write(&tmp, &json).is_ok() {
+                            let _ = std::fs::rename(&tmp, &path);
+                        }
+                    }
+                })
+                .await;
+            }
+        });
+
+        Self { counts, flush_tx }
+    }
+
+    pub fn increment(&self, path: String) {
+        {
+            let mut map = self.counts.lock().unwrap();
+            *map.entry(path).or_insert(0) += 1;
+        }
+        let _ = self.flush_tx.try_send(());
+    }
+
+    pub fn get_views(&self, path: &str) -> u64 {
+        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
+    }
+}
diff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js
new file mode 100644
index 0000000000000000000000000000000000000000..5cdf32836faf4b06930bcf048cf8cb1b1c1e574e
--- /dev/null
+++ b/server/static/sorter_ui.js
@@ -0,0 +1,34 @@
+/**
+ * sorter2 web UI: fetch/eval for POST /ui. No product logic here.
+ */
+(function () {
+  function evalJs(js) {
+    if (js && String(js).trim()) {
+      eval(js);
+    }
+  }
+
+  function initSorterUi() {
+    document.addEventListener('submit', async function (e) {
+      var f = e.target;
+      if (!f || f.tagName !== 'FORM') return;
+      if ((f.method || 'get').toLowerCase() !== 'post') return;
+      if (f.id === 'sorter-theme-form') return;
+      if (f.getAttribute('data-navigate') === 'full') return;
+      e.preventDefault();
+      var resp = await fetch(f.action, {
+        method: 'POST',
+        body: new URLSearchParams(new FormData(f)),
+        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+        credentials: 'same-origin',
+      });
+      evalJs(await resp.text());
+    });
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', initSorterUi);
+  } else {
+    initSorterUi();
+  }
+})();
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
new file mode 100644
index 0000000000000000000000000000000000000000..a4ea4d6c4d3af918257365150555515f0bb8b944
--- /dev/null
+++ b/server/static/theme_default.css
@@ -0,0 +1,87 @@
+:root {
+  --bg: #0f1115;
+  --fg: #e8eaed;
+  --muted: #9aa0a6;
+  --accent: #7cacf8;
+  --panel: #1a1d24;
+  --border: #2a2f3a;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  font-family: system-ui, -apple-system, sans-serif;
+  background: var(--bg);
+  color: var(--fg);
+  line-height: 1.5;
+}
+
+.muted {
+  color: var(--muted);
+}
+
+.small {
+  font-size: 0.875rem;
+}
+
+.view-meta {
+  position: fixed;
+  top: 0.5rem;
+  right: 0.5rem;
+  font-size: 0.75rem;
+}
+
+#controls {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  gap: 1rem;
+  align-items: center;
+  padding: 0.75rem 1rem;
+  background: var(--panel);
+  border-top: 1px solid var(--border);
+  font-size: 0.875rem;
+}
+
+#controls a {
+  color: var(--accent);
+}
+
+.demo-panel {
+  max-width: 40rem;
+  margin: 4rem auto 6rem;
+  padding: 2rem;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+}
+
+.demo-panel h1 {
+  margin-top: 0;
+}
+
+.btn-primary {
+  background: var(--accent);
+  color: #0f1115;
+  border: none;
+  padding: 0.5rem 1rem;
+  border-radius: 4px;
+  font-size: 1rem;
+  cursor: pointer;
+}
+
+.btn-primary:hover {
+  filter: brightness(1.1);
+}
+
+code {
+  font-size: 0.85em;
+  background: var(--bg);
+  padding: 0.1em 0.35em;
+  border-radius: 3px;
+}
diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
new file mode 100644
index 0000000000000000000000000000000000000000..ceee99494b873a6e658857be97f02ee739db85e9
--- /dev/null
+++ b/server/static/theme_retro.css
@@ -0,0 +1,5 @@
+@import url("/static/theme_default.css");
+
+:root {
+  --accent: #f4a261;
+}
diff --git a/server/tests/integration_health.rs b/server/tests/integration_health.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e7940d4e40a9be61b113403752f78d69ae18628f
--- /dev/null
+++ b/server/tests/integration_health.rs
@@ -0,0 +1,54 @@
+use std::net::SocketAddr;
+
+use axum::Router;
+use sorter2_server::{create_app, create_app_state, state::AppConfig};
+use tempfile::TempDir;
+use tokio::net::TcpListener;
+
+async fn start_test_server() -> (SocketAddr, TempDir) {
+    let tmp = TempDir::new().unwrap();
+    let data = tmp.path().to_string_lossy().into_owned();
+    let cfg = AppConfig {
+        data_dir: data.clone(),
+        event_log_path: format!("{data}/events.jsonl"),
+        port: 0,
+    };
+    let state = create_app_state(cfg).await;
+    let app: Router = create_app(state);
+
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move {
+        axum::serve(listener, app).await.unwrap();
+    });
+    (addr, tmp)
+}
+
+#[tokio::test]
+async fn healthz_ok() {
+    let (addr, _tmp) = start_test_server().await;
+    let client = reqwest::Client::new();
+    let resp = client
+        .get(format!("http://{addr}/healthz"))
+        .send()
+        .await
+        .unwrap();
+    assert!(resp.status().is_success());
+    assert_eq!(resp.text().await.unwrap(), "ok");
+}
+
+#[tokio::test]
+async fn home_has_demo_panel() {
+    let (addr, _tmp) = start_test_server().await;
+    let client = reqwest::Client::new();
+    let html = client
+        .get(format!("http://{addr}/"))
+        .send()
+        .await
+        .unwrap()
+        .text()
+        .await
+        .unwrap();
+    assert!(html.contains("demo-counter-panel"));
+    assert!(html.contains("__rpc__"));
+}
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
new file mode 100644
index 0000000000000000000000000000000000000000..538868371c6e88c3bc7ae83d931039a168fc404f
--- /dev/null
+++ b/server/tests/integration_ui.rs
@@ -0,0 +1,53 @@
+use std::collections::HashMap;
+use std::net::SocketAddr;
+
+use axum::Router;
+use sorter2_server::{create_app, create_app_state, state::AppConfig, ui_action::UI_RPC_FIELD};
+use tempfile::TempDir;
+use tokio::net::TcpListener;
+
+async fn start_test_server() -> (SocketAddr, TempDir) {
+    let tmp = TempDir::new().unwrap();
+    let data = tmp.path().to_string_lossy().into_owned();
+    let cfg = AppConfig {
+        data_dir: data.clone(),
+        event_log_path: format!("{data}/events.jsonl"),
+        port: 0,
+    };
+    let state = create_app_state(cfg).await;
+    let app: Router = create_app(state);
+
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move {
+        axum::serve(listener, app).await.unwrap();
+    });
+    (addr, tmp)
+}
+
+#[tokio::test]
+async fn post_ui_bump_returns_javascript_morph() {
+    let (addr, tmp) = start_test_server().await;
+    let rpc = serde_json::json!({ "action": "bump_demo_counter" }).to_string();
+    let mut form = HashMap::new();
+    form.insert(UI_RPC_FIELD.to_string(), rpc);
+
+    let client = reqwest::Client::new();
+    let body = client
+        .post(format!("http://{addr}/ui"))
+        .form(&form)
+        .send()
+        .await
+        .unwrap()
+        .text()
+        .await
+        .unwrap();
+
+    assert!(body.contains("Idiomorph.morph"));
+    assert!(body.contains("demo-counter-panel"));
+    assert!(body.contains("Counter:"));
+
+    let log_path = tmp.path().join("events.jsonl");
+    let log = std::fs::read_to_string(log_path).unwrap();
+    assert!(log.contains("demo_counter_bumped"));
+}
diff --git a/test/smoke.clj b/test/smoke.clj
new file mode 100644
index 0000000000000000000000000000000000000000..ae3e778383b76db49245bd8240c17be5292097cd
--- /dev/null
+++ b/test/smoke.clj
@@ -0,0 +1,55 @@
+(ns test.smoke
+  (:require [babashka.process :as process]
+            [clojure.java.io :as io]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest is testing]]))
+
+(defn- repo-root []
+  (.getCanonicalPath (io/file (System/getProperty "user.dir"))))
+
+(defn- pick-port []
+  (with-open [s (java.net.ServerSocket. 0)]
+    (.getLocalPort s)))
+
+(defn- wait-health [base-url ms]
+  (let [deadline (+ (System/currentTimeMillis) ms)
+        url (str base-url "/healthz")]
+    (loop []
+      (let [resp (try
+                   (process/shell {:out :string :err :string}
+                                  "curl" "-sf" url)
+                   (catch Exception _ nil))]
+        (if (and resp (zero? (:exit resp)) (= "ok" (str/trim (:out resp ""))))
+          true
+          (if (< (System/currentTimeMillis) deadline)
+            (do (Thread/sleep 200) (recur))
+            false))))))
+
+(deftest http-smoke-against-running-server
+  (testing "build, start, healthz, home contains RPC demo"
+    (let [root (repo-root)
+          data-dir (.getAbsolutePath (doto (io/file (System/getProperty "java.io.tmpdir")
+                                                   (str "sorter2-smoke-" (System/currentTimeMillis)))
+                                     (.mkdirs)))
+          port (pick-port)
+          base (str "http://127.0.0.1:" port)
+          bin (str root "/target/release/sorter2-server")]
+      (is (zero? (:exit (process/shell {:dir root}
+                                      "cargo" "build" "--release" "--package" "sorter2-server")))
+          "release build succeeds")
+      (is (.exists (io/file bin)) "binary exists")
+      (let [proc (process/process {:dir root
+                                   :env {"SORTER2_DATA_DIR" data-dir
+                                         "SORTER2_EVENT_LOG" (str data-dir "/events.jsonl")
+                                         "PORT" (str port)}
+                                   :out :string
+                                   :err :string}
+                                  bin)]
+        (try
+          (is (wait-health base 15000) "server responds to /healthz")
+          (let [home (:out (process/shell {:out :string :err :string}
+                                         "curl" "-sf" (str base "/")))]
+            (is (str/includes? home "demo-counter-panel"))
+            (is (str/includes? home "__rpc__")))
+          (finally
+            (process/destroy proc)))))))
diff --git a/tests.edn b/tests.edn
new file mode 100644
index 0000000000000000000000000000000000000000..9dcdfa1c922b3adb054c0480f37d411df8b2b6a1
--- /dev/null
+++ b/tests.edn
@@ -0,0 +1,11 @@
+#kaocha/v1
+{:tests
+ [{:id :all
+   :test-paths ["test"]
+   :source-paths ["."]
+   :ns-patterns ["^test\\..+"]
+   :kaocha.filter/skip-meta [:skip]
+   :parallel? false}]
+ :plugins [:kaocha.plugin/junit-xml]
+ :kaocha.plugin.junit-xml/target-file "target/kaocha-junit.xml"
+ :reporter kaocha.report.progress/report}
diff --git a/ui_action.rs b/ui_action.rs
deleted file mode 100644
index 5e4131dd346a6f80bfe091a9b0cf7902721bc47f..0000000000000000000000000000000000000000
--- a/ui_action.rs
+++ /dev/null
@@ -1,270 +0,0 @@
-//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).
-//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).
-
-use crate::form_template::fill_template_from_form;
-use serde::{Deserialize, Serialize};
-use serde_json::Value;
-use std::collections::HashMap;
-use thiserror::Error;
-
-/// Form field name for the compact JSON template (possibly with `{"$form":"…"}` holes).
-pub const UI_RPC_FIELD: &str = "__rpc__";
-
-fn default_ui_form_action() -> String {
-    "/ui".to_string()
-}
-
-/// HTML form / fetch `POST /ui` payload after template fill and deserialization.
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-#[serde(tag = "action", rename_all = "snake_case")]
-pub enum HtmlUiAction {
-    /// Forum ingest via `POST /ui`.
-    PostIngest {
-        room: String,
-        thread_tag: String,
-        text: String,
-        #[serde(default)]
-        error_target: Option,
-        #[serde(default)]
-        form_id: Option,
-    },
-    /// DSL check / validation via `POST /ui`.
-    CheckIngest {
-        room: String,
-        thread_tag: String,
-        text: String,
-        #[serde(default)]
-        error_target: Option,
-        #[serde(default)]
-        form_id: Option,
-    },
-    /// Post a pairwise vote from `/vote` (browser compose).
-    VoteComparePost {
-        room: String,
-        thread_tag: String,
-        left_item: String,
-        right_item: String,
-        /// From form fields (string); parsed server-side.
-        ratio_left: String,
-        ratio_right: String,
-        explanation: String,
-        /// Same-origin path after successful post (JS redirect).
-        next: String,
-        /// Pool parent item path, if the vote was initiated from a pool URL.
-        #[serde(default)]
-        pool: Option,
-        #[serde(default = "default_ui_form_action")]
-        form_action: String,
-    },
-    /// 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`.
-    SetGardenPin {
-        #[serde(default)]
-        clear: bool,
-        #[serde(default)]
-        room_wire: String,
-        #[serde(default)]
-        item_storage: Option,
-        next: String,
-        /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.
-        #[serde(default = "default_ui_form_action")]
-        form_action: String,
-    },
-    /// Resolve children or siblings for an external garden item.
-    ResolveExternal {
-        room_wire: String,
-        item_storage: String,
-        mode: String,
-        next: String,
-        #[serde(default = "default_ui_form_action")]
-        form_action: String,
-    },
-    /// Author redacts own post via `POST /ui`.
-    RedactPost { post_id: String },
-    /// Morph `#room-members-section` — members list open or collapsed (server-rendered).
-    SetRoomMembersExpanded {
-        room_wire: String,
-        #[serde(default)]
-        expanded: bool,
-    },
-    /// Delete the private room (Manage only); redirects to `/` on success.
-    DeleteRoom { room: String },
-    /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home).
-    SetNewThreadComposeExpanded {
-        room_wire: String,
-        #[serde(default)]
-        expanded: bool,
-    },
-    /// Replace a truncated post card with the full body (same thread index).
-    ExpandPostFull {
-        room: String,
-        thread_tag: String,
-        post_index: usize,
-    },
-    /// Expand a redacted/tombstone post to show stripped body (author view).
-    ExpandRedactedPost {
-        room: String,
-        thread_tag: String,
-        post_index: usize,
-    },
-    /// Collapse an expanded redacted post back to the tombstone card.
-    CollapseRedactedPost {
-        room: String,
-        thread_tag: String,
-        post_index: usize,
-    },
-    /// Copy full thread text (CLI `forum show` format) to the clipboard.
-    CopyThread {
-        room: String,
-        thread_tag: String,
-        copy_btn_id: String,
-    },
-}
-
-#[derive(Debug, Error)]
-pub enum HtmlUiParseError {
-    #[error("missing __rpc__ field")]
-    MissingRpc,
-    #[error("invalid template json: {0}")]
-    Template(serde_json::Error),
-    #[error("invalid ui action: {0}")]
-    Action(serde_json::Error),
-}
-
-/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.
-pub fn parse_html_ui_from_form(
-    form: &HashMap,
-) -> Result {
-    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;
-    let mut hole_map = form.clone();
-    hole_map.remove(UI_RPC_FIELD);
-    let v: Value =
-        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;
-    serde_json::from_value(v).map_err(HtmlUiParseError::Action)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn round_trip_post_ingest_with_holes() {
-        let template = serde_json::json!({
-            "action": "post_ingest",
-            "room": "public",
-            "thread_tag": {"$form": "thread_tag"},
-            "text": {"$form": "text"},
-            "error_target": "e",
-            "form_id": "f",
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        form.insert("thread_tag".into(), "x".into());
-        form.insert("text".into(), "body".into());
-
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::PostIngest {
-                room: "public".into(),
-                thread_tag: "x".into(),
-                text: "body".into(),
-                error_target: Some("e".into()),
-                form_id: Some("f".into()),
-            }
-        );
-    }
-
-    #[test]
-    fn expand_post_full_round_trip() {
-        let template = serde_json::json!({
-            "action": "expand_post_full",
-            "room": "public",
-            "thread_tag": "demo",
-            "post_index": 3,
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::ExpandPostFull {
-                room: "public".into(),
-                thread_tag: "demo".into(),
-                post_index: 3,
-            }
-        );
-    }
-
-    #[test]
-    fn set_room_members_expanded_defaults_false() {
-        let template = serde_json::json!({
-            "action": "set_room_members_expanded",
-            "room_wire": "ab/cd",
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::SetRoomMembersExpanded {
-                room_wire: "ab/cd".into(),
-                expanded: false,
-            }
-        );
-    }
-
-    #[test]
-    fn copy_thread_round_trip() {
-        let template = serde_json::json!({
-            "action": "copy_thread",
-            "room": "public",
-            "thread_tag": "demo",
-            "copy_btn_id": "thread-copy-top",
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::CopyThread {
-                room: "public".into(),
-                thread_tag: "demo".into(),
-                copy_btn_id: "thread-copy-top".into(),
-            }
-        );
-    }
-
-    #[test]
-    fn set_new_thread_compose_expanded_true() {
-        let template = serde_json::json!({
-            "action": "set_new_thread_compose_expanded",
-            "room_wire": "ab/cd",
-            "expanded": true,
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::SetNewThreadComposeExpanded {
-                room_wire: "ab/cd".into(),
-                expanded: true,
-            }
-        );
-    }
-}
diff --git a/views.rs b/views.rs
deleted file mode 100644
index d4f0ffc49475f014698b4da0de6f476884430813..0000000000000000000000000000000000000000
--- a/views.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use std::{
-    collections::HashMap,
-    sync::{Arc, Mutex},
-};
-use tokio::sync::mpsc;
-
-type CountMap = Arc>>;
-
-#[derive(Clone)]
-pub struct ViewStore {
-    counts: CountMap,
-    flush_tx: mpsc::Sender<()>,
-}
-
-impl ViewStore {
-    pub fn new(json_path: &str) -> Self {
-        // Load existing counts from disk on startup (best-effort)
-        let initial: HashMap = std::fs::read_to_string(json_path)
-            .ok()
-            .and_then(|s| serde_json::from_str(&s).ok())
-            .unwrap_or_default();
-
-        let counts: CountMap = Arc::new(Mutex::new(initial));
-        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
-        let path = json_path.to_string();
-
-        let counts_for_writer = counts.clone();
-        tokio::spawn(async move {
-            while flush_rx.recv().await.is_some() {
-                while flush_rx.try_recv().is_ok() {}
-
-                let snapshot: HashMap = {
-                    counts_for_writer.lock().unwrap().clone()
-                };
-
-                let path = path.clone();
-                let _ = tokio::task::spawn_blocking(move || {
-                    if let Ok(json) = serde_json::to_string(&snapshot) {
-                        let tmp = format!("{path}.tmp");
-                        if std::fs::write(&tmp, &json).is_ok() {
-                            let _ = std::fs::rename(&tmp, &path);
-                        }
-                    }
-                })
-                .await;
-            }
-        });
-
-        Self { counts, flush_tx }
-    }
-
-    pub fn increment(&self, path: String) {
-        {
-            let mut map = self.counts.lock().unwrap();
-            *map.entry(path).or_insert(0) += 1;
-        }
-        let _ = self.flush_tx.try_send(());
-    }
-
-    pub fn get_views(&self, path: &str) -> u64 {
-        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
-    }
-}
diff --git a/vote.rs b/vote.rs
deleted file mode 100644
index d0ec78cd675eae284d056fb3b8eaf5cc853d6263..0000000000000000000000000000000000000000
--- a/vote.rs
+++ /dev/null
@@ -1,580 +0,0 @@
-use axum::{
-    extract::{Path, Query, State},
-    http::{HeaderMap, StatusCode, Uri},
-    response::{Html, IntoResponse},
-};
-use axum_extra::extract::cookie::CookieJar;
-use maud::html;
-use serde::Deserialize;
-use serde_json::json;
-use std::collections::{HashMap, HashSet};
-
-use crate::{
-    api::optional_principal,
-    canonical_path::canonicalize_tag,
-    form_template::template_json_compact,
-    html::{
-        forum::ThreadNav,
-        layout_full_bleed_chromeless,
-        ratio_pct, render_item_body_in_scope,
-        theme_from_jar, theme_next_from_uri,
-        user_can_post_room,
-        ui_action::UI_RPC_FIELD,
-        JsBuilder,
-    },
-    middleware::canonical_view_url,
-    path_types::ItemId,
-    reducer::{ContentState, ScopeId},
-    scope_rank::suggest_next_pair_in_pool,
-    state::AppState,
-};
-
-use super::{
-    access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},
-    item::{item_display_path, login_href_with_next},
-};
-
-fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {
-    let cands: HashSet = content
-        .item_threads
-        .get(a)
-        .into_iter()
-        .chain(content.item_threads.get(b))
-        .flat_map(|s| s.iter().cloned())
-        .collect();
-    if cands.is_empty() {
-        return "vote".to_string();
-    }
-    let mut v: Vec = cands.into_iter().collect();
-    v.sort();
-    canonicalize_tag(&v[0])
-}
-
-/// Canonical unordered pair: lexicographic by storage string (stable edge identity).
-pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {
-    let ac = a.clone().normalized_storage();
-    let bc = b.clone().normalized_storage();
-    if ac.as_str() <= bc.as_str() {
-        (ac, bc)
-    } else {
-        (bc, ac)
-    }
-}
-
-/// All votes whose endpoints are exactly this unordered pair (unsorted).
-pub(super) fn edge_vote_entries_for_pair(
-    content: &ContentState,
-    a: &ItemId,
-    b: &ItemId,
-) -> Vec {
-    let (lo, hi) = canonical_edge_items(a, b);
-    let lo_s = lo.as_str();
-    let hi_s = hi.as_str();
-    content
-        .item_votes
-        .get(&lo)
-        .into_iter()
-        .flat_map(|q| q.iter())
-        .filter(|v| {
-            (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
-                || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
-        })
-        .cloned()
-        .collect()
-}
-
-pub(super) fn ratios_for_compare_page(
-    v: &crate::reducer::VoteData,
-    page_left: &ItemId,
-    page_right: &ItemId,
-) -> (i32, i32) {
-    let pl = page_left.as_str();
-    let pr = page_right.as_str();
-    match (v.a.as_str(), v.b.as_str()) {
-        (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right),
-        (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left),
-        _ => (v.ratio_left, v.ratio_right),
-    }
-}
-
-fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {
-    let l = ratio_left.max(0) as f64;
-    let r = ratio_right.max(0) as f64;
-    let sum = l + r;
-    if sum <= 0.0 {
-        0.5
-    } else {
-        l / sum
-    }
-}
-
-/// Stronger preference for **`page_left` first**; ties **newer first**.
-pub(super) fn sort_votes_for_compare_display(
-    mut votes: Vec,
-    page_left: &ItemId,
-    page_right: &ItemId,
-) -> Vec {
-    votes.sort_by(|va, vb| {
-        let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right);
-        let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right);
-        let sa = left_share_normalized(ratio_left_a, ratio_right_a);
-        let sb = left_share_normalized(ratio_left_b, ratio_right_b);
-        match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {
-            std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts),
-            o => o,
-        }
-    });
-    votes
-}
-
-/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).
-pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {
-    let (lo, hi) = canonical_edge_items(a, b);
-    let lo_s = lo.as_str();
-    let hi_s = hi.as_str();
-    content
-        .item_votes
-        .get(&lo)
-        .into_iter()
-        .flat_map(|q| q.iter())
-        .filter(|v| {
-            (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
-                || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
-        })
-        .count()
-}
-
-fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {
-    let set: HashSet = content
-        .item_threads
-        .get(a)
-        .into_iter()
-        .chain(content.item_threads.get(b))
-        .flat_map(|s| s.iter().cloned())
-        .collect();
-    let mut v: Vec = set.into_iter().collect();
-    v.sort();
-    v.into_iter().map(|t| canonicalize_tag(&t)).collect()
-}
-
-fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {
-    let votes = edge_vote_entries_for_pair(content, left, right);
-    let votes = sort_votes_for_compare_display(votes, left, right);
-    let legend_left = item_display_path(left.as_str());
-    let legend_right = item_display_path(right.as_str());
-    html! {
-        @if votes.is_empty() {
-            p class="muted vote-edge-empty" { "no votes on this pair in this scope yet" }
-        } @else {
-            h3 class="vote-edge-history-title" {
-                "votes on this edge"
-                span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) }
-            }
-            ul class="vote-edge-history" {
-                @for v in &votes {
-                    @let (r_left, r_right) = ratios_for_compare_page(v, left, right);
-                    @let pct = ratio_pct(r_left, r_right);
-                    @let row_tip = format!(
-                        "{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}",
-                        r_left,
-                        r_right,
-                        legend_left,
-                        legend_right,
-                        v.thread_tag,
-                        v.principal,
-                    );
-                    li class="vote-edge-history-row" title=(row_tip) {
-                        div class="vote-edge-meta" {
-                            span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
-                            span class="muted" { " · #" (v.thread_tag) " · @" (v.principal) }
-                        }
-                        div class="ratio-bar vote-edge-bar" aria-hidden="true" {
-                            div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
-                            div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
-                        }
-                        @if !v.body.trim().is_empty() {
-                            div class="vote-edge-reason muted" { (v.body.trim()) }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
-
-/// After a successful vote post: refresh edge history (no in-page preview card).
-#[allow(clippy::too_many_arguments)]
-pub(crate) async fn vote_compare_post_success_js(
-    state: &AppState,
-    nav: &ThreadNav,
-    _room_wire: &str,
-    _thread_tag: &str,
-    left: &ItemId,
-    right: &ItemId,
-    pool: Option<&ItemId>,
-    _post_id: &str,
-    _post_idx: Option,
-) -> String {
-    let reduced = state.reduced.read().await;
-    let content = content_for_garden_view(&reduced, &nav.scope());
-    let edge_history = vote_edge_history_markup(content, left, right);
-    let next_pair = suggest_next_vote_pair(content, left, right, pool);
-    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);
-    drop(reduced);
-    JsBuilder::new()
-        .morph_inner_selector("#vote-edge-history-region", edge_history)
-        .morph_selector(".vote-compare-nav", nav_markup)
-        .build()
-}
-
-pub(super) fn vote_compare_href(
-    nav: &ThreadNav,
-    left: &ItemId,
-    right: &ItemId,
-    thread_override: Option<&str>,
-    pool: Option<&ItemId>,
-) -> String {
-    let left_dp = left.display_path();
-    let right_dp = right.display_path();
-    let left_q = urlencoding::encode(&left_dp);
-    let right_q = urlencoding::encode(&right_dp);
-    let mut base = format!(
-        "{}/vote?left={}&right={}",
-        nav.room_path_prefix_for_vote_compare(),
-        left_q,
-        right_q
-    );
-    if let Some(t) = thread_override.filter(|s| !s.is_empty()) {
-        base = format!("{}&thread={}", base, urlencoding::encode(t));
-    }
-    if let Some(p) = pool {
-        let pool_dp = p.display_path();
-        base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp));
-    }
-    base
-}
-
-pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
-    let display = ItemId::parse(pool_item_str)
-        .map(|i| i.display_path())
-        .unwrap_or_else(|| pool_item_str.to_string());
-    format!(
-        "{}/vote?pool={}",
-        nav.room_path_prefix_for_vote_compare(),
-        urlencoding::encode(&display)
-    )
-}
-
-fn vote_compare_nav_markup(
-    nav: &ThreadNav,
-    next_pair: Option<&(ItemId, ItemId)>,
-    pool: Option<&ItemId>,
-) -> maud::Markup {
-    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));
-    html! {
-        div class="vote-compare-nav" {
-            @if let Some(href) = &next_pair_href {
-                a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" }
-            } @else {
-                span class="vote-compare-next is-disabled" { "no next pair" }
-            }
-        }
-    }
-}
-
-pub(super) fn suggest_next_vote_pair(
-    content: &ContentState,
-    current_left: &ItemId,
-    current_right: &ItemId,
-    pool_parent: Option<&ItemId>,
-) -> Option<(ItemId, ItemId)> {
-    let pool: Vec = if let Some(parent) = pool_parent {
-        content
-            .item_children
-            .get(parent)
-            .map(|s| s.iter().cloned().collect())
-            .unwrap_or_default()
-    } else if current_left.parent().as_ref().map(|p| p.as_str())
-        == current_right.parent().as_ref().map(|p| p.as_str())
-    {
-        current_left
-            .parent()
-            .and_then(|parent| {
-                content
-                    .item_children
-                    .get(&parent.normalized_storage())
-                    .cloned()
-            })
-            .map(|children| children.into_iter().collect())
-            .unwrap_or_default()
-    } else {
-        Vec::new()
-    };
-    if pool.len() < 2 {
-        return None;
-    }
-    suggest_next_pair_in_pool(
-        &content.ranking_group,
-        &pool,
-        Some((current_left, current_right)),
-    )
-}
-
-pub(super) fn vote_compare_item_card(
-    nav: &ThreadNav,
-    item: &ItemId,
-    body: Option<&String>,
-    side_class: &str,
-    item_bodies: Option<&HashMap>,
-) -> maud::Markup {
-    html! {
-        div class=(format!("vote-compare-side {side_class}")) {
-            a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) {
-                code { (item_display_path(item.as_str())) }
-            }
-            @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {
-                div class="vote-compare-item-body" {
-                    (render_item_body_in_scope(
-                        body,
-                        nav.garden_root_url(),
-                        item_bodies,
-                    ))
-                }
-            } @else {
-                p class="muted vote-compare-item-body-empty" { "no body yet" }
-            }
-        }
-    }
-}
-#[derive(Debug, Deserialize)]
-pub struct VoteCompareQuery {
-    #[serde(default)]
-    pub left: Option,
-    #[serde(default)]
-    pub right: Option,
-    #[serde(default)]
-    pub thread: Option,
-    #[serde(default)]
-    pub pool: Option,
-}
-
-/// Public pairwise vote UI — `/vote?left=&right=&thread=`.
-pub async fn vote_compare_page(
-    State(state): State,
-    Query(q): Query,
-    headers: HeaderMap,
-    jar: CookieJar,
-    uri: Uri,
-) -> impl IntoResponse {
-    let nav = ThreadNav::public();
-    vote_compare_inner(state, q, nav, headers, jar, uri).await
-}
-
-pub async fn room_vote_compare_page(
-    State(state): State,
-    Path(room_key): Path,
-    Query(q): Query,
-    headers: HeaderMap,
-    jar: CookieJar,
-    uri: Uri,
-) -> impl IntoResponse {
-    let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
-        return (StatusCode::NOT_FOUND, "bad room path").into_response();
-    };
-    let Some(nav) = ThreadNav::from_room_id(&room_id) else {
-        return (StatusCode::NOT_FOUND, "bad room path").into_response();
-    };
-    let reduced = state.reduced.read().await;
-    let user = optional_principal(&headers, &jar, &reduced);
-    if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
-        drop(reduced);
-        return room_not_found_page(&jar, &uri).into_response();
-    }
-    if !room_scope_has_garden_content(&reduced, &nav) {
-        drop(reduced);
-        return room_not_found_page(&jar, &uri).into_response();
-    }
-    drop(reduced);
-    vote_compare_inner(state, q, nav, headers, jar, uri).await
-}
-
-async fn vote_compare_inner(
-    state: AppState,
-    q: VoteCompareQuery,
-    nav: ThreadNav,
-    headers: HeaderMap,
-    jar: CookieJar,
-    uri: Uri,
-) -> axum::response::Response {
-    let pool_id: Option = match q.pool.as_deref() {
-        Some(p) => match ItemId::parse(p.trim()) {
-            Some(i) => Some(i.normalized_storage()),
-            None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(),
-        },
-        None => None,
-    };
-
-    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {
-        (Some(l), Some(r)) => {
-            let left = match ItemId::parse(l.trim()) {
-                Some(i) => i.normalized_storage(),
-                None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
-            };
-            let right = match ItemId::parse(r.trim()) {
-                Some(i) => i.normalized_storage(),
-                None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
-            };
-            if left == right {
-                return (StatusCode::BAD_REQUEST, "items must differ").into_response();
-            }
-            (left, right)
-        }
-        (None, None) => {
-            let Some(pool) = pool_id.as_ref() else {
-                return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response();
-            };
-            let reduced = state.reduced.read().await;
-            let content = content_for_garden_view(&reduced, &nav.scope());
-            let children: Vec = content
-                .item_children
-                .get(pool)
-                .map(|s| s.iter().cloned().collect())
-                .unwrap_or_default();
-            if children.len() < 2 {
-                drop(reduced);
-                return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response();
-            }
-            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);
-            drop(reduced);
-            match pair {
-                Some(p) => p,
-                None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(),
-            }
-        }
-        _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(),
-    };
-
-    let reduced = state.reduced.read().await;
-    let content = content_for_garden_view(&reduced, &nav.scope());
-    let viewer = optional_principal(&headers, &jar, &reduced);
-    let can_post = match &nav.scope() {
-        ScopeId::Public => viewer.is_some(),
-        ScopeId::Room(rid) => viewer
-            .as_ref()
-            .map(|u| user_can_post_room(&reduced, rid, u))
-            .unwrap_or(false),
-    };
-    let auto_thread = q
-        .thread
-        .as_ref()
-        .map(|t| canonicalize_tag(t))
-        .filter(|t| !t.is_empty())
-        .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));
-    let thread_tags = vote_thread_tags_for_pair(content, &left, &right);
-    let edge_history = vote_edge_history_markup(content, &left, &right);
-    let left_body = content.item_bodies.get(&left).cloned();
-    let right_body = content.item_bodies.get(&right).cloned();
-    let item_bodies_for_cards = content.item_bodies.clone();
-    let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());
-    drop(reduced);
-
-    let title = format!(
-        "vote — {} vs {}",
-        item_display_path(left.as_str()),
-        item_display_path(right.as_str())
-    );
-    let next_path = uri
-        .path_and_query()
-        .map(|pq| pq.as_str().to_string())
-        .unwrap_or_else(|| "/vote".into());
-
-    let rpc_json = template_json_compact(&json!({
-        "action": "vote_compare_post",
-        "room": nav.room_wire,
-        "thread_tag": {"$form": "thread_tag"},
-        "left_item": left.as_str(),
-        "right_item": right.as_str(),
-        "ratio_left": {"$form": "ratio_left"},
-        "ratio_right": {"$form": "ratio_right"},
-        "explanation": {"$form": "explanation"},
-        "next": next_path,
-        "pool": pool_id.as_ref().map(|p| p.as_str()),
-        "form_action": "/ui",
-    }))
-    .expect("vote compare rpc json");
-
-    let body = html! {
-    section class="vote-compare-shell" {
-        h2 { "compare" }
-        div class="vote-compare-pair" {
-            (vote_compare_item_card(
-                &nav,
-                &left,
-                left_body.as_ref(),
-                "vote-compare-left",
-                Some(&item_bodies_for_cards),
-            ))
-            span class="vote-compare-vs" { "vs" }
-            (vote_compare_item_card(
-                &nav,
-                &right,
-                right_body.as_ref(),
-                "vote-compare-right",
-                Some(&item_bodies_for_cards),
-            ))
-        }
-        (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))
-        div id="vote-edge-history-region" {
-            (edge_history)
-        }
-        @if can_post {
-            form id="vote-compare-form" method="POST" action="/ui" {
-                input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
-                div class="vote-thread-picker" {
-                    label class="vote-thread-picker-label" { "thread" }
-                    select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" {
-                        @if thread_tags.is_empty() {
-                            option value="vote" selected { "#vote" }
-                        }
-                        @for t in &thread_tags {
-                            @if *t == auto_thread {
-                                option value=(t) selected { "#" (t) }
-                            } @else {
-                                option value=(t) { "#" (t) }
-                            }
-                        }
-                    }
-                }
-                input type="hidden" name="ratio_left" id="vote-ratio-left" value="50";
-                input type="hidden" name="ratio_right" id="vote-ratio-right" value="50";
-                label class="vote-compare-slider-label" {
-                    span id="vote-slider-left-label" { (item_display_path(left.as_str())) }
-                    input type="range" id="vote-preference-slider" min="0" max="100" value="50"
-                        aria-valuemin="0" aria-valuemax="100";
-                    span id="vote-slider-right-label" { (item_display_path(right.as_str())) }
-                }
-                label class="vote-explain-label" { "reason (required)" }
-                textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {}
-                div id="vote-compare-errors" {}
-                p { button type="submit" { "post vote" } }
-            }
-        } @else {
-            p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." }
-        }
-    }
-    };
-
-    let url_key = canonical_view_url(&uri);
-    let view_count = state.views.get_views(&url_key);
-
-    let page = layout_full_bleed_chromeless(
-        &title,
-        "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen",
-        body,
-        Some(view_count),
-        theme_from_jar(&jar),
-        &theme_next_from_uri(&uri),
-    );
-    Html(page.into_string()).into_response()
-}
-