{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[d6b4a41e] Turn /login into an account page with alias switching.\n\nSigned-in users can see trust weight, switch aliases, claim new ones, and re-link OAuth from one place.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs\nindex d4a85ef52c15dc35148e4c743f0d646cbbdb056d..5906f93b13853421e96a3c37bc9d8202a47842bf 100644\n--- a/server/src/auth/mod.rs\n+++ b/server/src/auth/mod.rs\n@@ -94,63 +94,169 @@ fn alias_list(db: &durable::Db, uuid: &str) -> Vec {\n .unwrap_or_default()\n }\n \n-fn login_body(\n- session: Option<&session::SessionActor>,\n+fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result {\n+ let check_rpc = template_json_compact(&serde_json::json!({\n+ \"action\": \"check_pseudonym\",\n+ \"pseudonym\": {\"$form\": \"pseudonym\"},\n+ }))\n+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n+ let claim_rpc = template_json_compact(&serde_json::json!({\n+ \"action\": \"claim_pseudonym\",\n+ \"pseudonym\": {\"$form\": \"pseudonym\"},\n+ \"return_to\": return_to,\n+ }))\n+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n+\n+ Ok(html! {\n+ div class=\"alias-claim\" {\n+ form id=\"alias-check-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(check_rpc);\n+ label for=\"alias-input\" { \"alias\" }\n+ input type=\"text\" id=\"alias-input\" name=\"pseudonym\" autocomplete=\"off\"\n+ data-testid=\"alias-input\" maxlength=\"64\" placeholder=\"letters, numbers, _ -\";\n+ p id=\"alias-status\" class=\"muted\" data-testid=\"alias-status\" { \"type to check availability\" }\n+ }\n+ form id=\"alias-claim-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(claim_rpc);\n+ input type=\"hidden\" name=\"pseudonym\" id=\"alias-claim-field\" value=\"\";\n+ button type=\"submit\" class=\"btn-primary\" data-testid=\"alias-claim\" { (submit_label) }\n+ }\n+ }\n+ })\n+}\n+\n+fn signed_out_body(providers: &[(&str, String)]) -> Markup {\n+ html! {\n+ main class=\"panel login-page\" {\n+ section class=\"login-section\" {\n+ h1 { \"sign in\" }\n+ p class=\"muted\" { \"link an account to vote under a lasting alias\" }\n+ @if providers.is_empty() {\n+ p class=\"muted\" {\n+ \"OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.\"\n+ }\n+ } @else {\n+ ul class=\"oauth-provider-list\" {\n+ @for (name, href) in providers {\n+ li {\n+ a href=(href) class=\"btn-primary oauth-provider\"\n+ data-testid=(format!(\"oauth-{}\", name.to_lowercase())) {\n+ (format!(\"Continue with {name}\"))\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ p class=\"login-back\" { a href=\"/\" { \"← back\" } }\n+ }\n+ }\n+}\n+\n+fn account_body(\n+ actor: &session::SessionActor,\n aliases: &[String],\n providers: &[(&str, String)],\n+ claim_forms: Markup,\n ) -> Markup {\n+ let current = actor.pseudonym.trim();\n html! {\n- main class=\"panel login-page\" {\n- div class=\"login-grid\" {\n- section class=\"login-oauth\" {\n- h1 { \"sign in\" }\n- @if providers.is_empty() {\n- p class=\"muted\" {\n- \"OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.\"\n- }\n- } @else {\n- ul class=\"oauth-provider-list\" {\n- @for (name, href) in providers {\n- li {\n- a href=(href) class=\"button oauth-provider\" data-testid=(format!(\"oauth-{}\", name.to_lowercase())) {\n- (format!(\"Continue with {name}\"))\n+ main class=\"panel login-page account-page\" {\n+ section class=\"login-section\" {\n+ h1 { \"account\" }\n+ @if current.is_empty() {\n+ p class=\"muted\" { \"finish setup by choosing an alias below\" }\n+ } @else {\n+ p class=\"account-current\" {\n+ \"voting as \"\n+ strong data-testid=\"account-current\" { (current) }\n+ }\n+ }\n+ p class=\"muted small\" data-testid=\"account-weight\" {\n+ \"trust weight \" (format!(\"{:.1}\", actor.trust_weight))\n+ \" · rises when you link more OAuth providers\"\n+ }\n+ }\n+\n+ section class=\"login-section\" {\n+ h2 { \"aliases\" }\n+ @if aliases.is_empty() {\n+ p class=\"muted\" data-testid=\"alias-list-empty\" { \"none yet — claim one below\" }\n+ } @else {\n+ ul id=\"alias-list\" class=\"alias-list\" data-testid=\"alias-list\" {\n+ @for alias in aliases {\n+ @let is_current = alias == current;\n+ li class=(if is_current { \"alias-item alias-current\" } else { \"alias-item\" }) {\n+ span class=\"alias-name\" { (alias) }\n+ @if is_current {\n+ span class=\"alias-badge\" data-testid=\"alias-current-badge\" { \"current\" }\n+ } @else {\n+ form class=\"alias-switch\" method=\"post\" action=\"/auth/switch\"\n+ data-navigate=\"full\" {\n+ input type=\"hidden\" name=\"pseudonym\" value=(alias);\n+ button type=\"submit\" class=\"btn-secondary\"\n+ data-testid=(format!(\"alias-switch-{alias}\")) {\n+ \"use\"\n+ }\n }\n }\n }\n }\n }\n- @if let Some(actor) = session {\n- p class=\"muted small\" {\n- \"session active · weight \" (format!(\"{:.1}\", actor.trust_weight))\n- }\n- form method=\"post\" action=\"/auth/logout\" data-navigate=\"full\" {\n- button type=\"submit\" { \"log out\" }\n- }\n- }\n }\n- section class=\"login-aliases\" {\n- h2 { \"your aliases\" }\n- ul id=\"alias-list\" class=\"alias-list\" {\n- @if aliases.is_empty() {\n- li class=\"muted\" data-testid=\"alias-list-empty\" { \"none yet\" }\n- } @else {\n- @for alias in aliases {\n- li { (alias) }\n+ }\n+\n+ section class=\"login-section\" {\n+ h2 { \"add alias\" }\n+ p class=\"muted small\" { \"each alias is unique across sorter2\" }\n+ (claim_forms)\n+ }\n+\n+ @if !providers.is_empty() {\n+ section class=\"login-section\" {\n+ h2 { \"linked sign-in\" }\n+ p class=\"muted small\" { \"sign in again with the same provider to return to this account\" }\n+ ul class=\"oauth-provider-list\" {\n+ @for (name, href) in providers {\n+ li {\n+ a href=(href) class=\"btn-secondary oauth-provider\"\n+ data-testid=(format!(\"oauth-relink-{}\", name.to_lowercase())) {\n+ (format!(\"Re-link {name}\"))\n+ }\n }\n }\n }\n }\n }\n- p { a href=\"/\" { \"← back\" } }\n+\n+ section class=\"login-section login-actions\" {\n+ form method=\"post\" action=\"/auth/logout\" data-navigate=\"full\" {\n+ button type=\"submit\" class=\"btn-secondary\" data-testid=\"account-logout\" { \"log out\" }\n+ }\n+ }\n+\n+ p class=\"login-back\" { a href=\"/\" { \"← back\" } }\n }\n }\n }\n \n+fn login_body(\n+ session: Option<&session::SessionActor>,\n+ aliases: &[String],\n+ providers: &[(&str, String)],\n+ claim_forms: Option,\n+) -> Markup {\n+ match (session, claim_forms) {\n+ (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms),\n+ _ => signed_out_body(providers),\n+ }\n+}\n+\n pub async fn login_page(\n State(state): State,\n jar: CookieJar,\n Query(query): Query,\n-) -> Response {\n+) -> Result {\n let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());\n let jar = jar.add(session::auth_return_cookie_value(&return_to));\n \n@@ -164,16 +270,26 @@ pub async fn login_page(\n .unwrap_or_default();\n let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to);\n \n+ let claim_forms = if session.is_some() {\n+ Some(alias_claim_forms(\"/login\", \"claim alias\")?)\n+ } else {\n+ None\n+ };\n+\n let markup = layout(\n- \"login · sorter2\",\n- login_body(session.as_ref(), &aliases, &providers),\n+ if session.is_some() {\n+ \"account · sorter2\"\n+ } else {\n+ \"login · sorter2\"\n+ },\n+ login_body(session.as_ref(), &aliases, &providers, claim_forms),\n state.views.get_views(\"/login\"),\n session\n .as_ref()\n .filter(|s| !s.pseudonym.trim().is_empty())\n .map(|s| s.pseudonym.as_str()),\n );\n- (jar, Html(markup.into_string())).into_response()\n+ Ok((jar, Html(markup.into_string())).into_response())\n }\n \n pub async fn alias_page(\n@@ -186,38 +302,17 @@ pub async fn alias_page(\n let db = state.projection_store.db();\n let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?;\n if session::session_has_pseudonym(&session) {\n- return Ok(Redirect::to(&return_to).into_response());\n+ // Already onboarded — manage aliases on the account page.\n+ return Ok(Redirect::to(\"/login\").into_response());\n }\n \n- let check_rpc = template_json_compact(&serde_json::json!({\n- \"action\": \"check_pseudonym\",\n- \"pseudonym\": {\"$form\": \"pseudonym\"},\n- }))\n- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n- let claim_rpc = template_json_compact(&serde_json::json!({\n- \"action\": \"claim_pseudonym\",\n- \"pseudonym\": {\"$form\": \"pseudonym\"},\n- \"return_to\": return_to,\n- }))\n- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;\n-\n+ let claim_forms = alias_claim_forms(&return_to, \"continue\")?;\n let body = html! {\n main class=\"panel alias-page\" {\n h1 { \"choose alias\" }\n p class=\"muted\" { \"pick a unique display name for your votes\" }\n- form id=\"alias-check-form\" method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(check_rpc);\n- label { \"alias\" }\n- input type=\"text\" id=\"alias-input\" name=\"pseudonym\" autocomplete=\"off\"\n- data-testid=\"alias-input\" maxlength=\"64\";\n- p id=\"alias-status\" class=\"muted\" data-testid=\"alias-status\" { \"type to check availability\" }\n- }\n- form id=\"alias-claim-form\" method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(claim_rpc);\n- input type=\"hidden\" name=\"pseudonym\" id=\"alias-claim-field\" value=\"\";\n- button type=\"submit\" class=\"btn-primary\" data-testid=\"alias-claim\" { \"continue\" }\n- }\n- p { a href=\"/login\" { \"← back to login\" } }\n+ (claim_forms)\n+ p class=\"login-back\" { a href=\"/login\" { \"← back to login\" } }\n }\n };\n \ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 3cc3d7bdf55b5cb5d009600f4ade1fcd201a410b..5b9b73c7ad24499737f0576b3603c3ff5355251e 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -149,7 +149,7 @@ pub(crate) fn layout(title: &str, body: Markup, views: u64, nav_user: Option<&st\n nav class=\"top-nav\" {\n @if let Some(name) = nav_user {\n span class=\"top-nav-user\" data-testid=\"nav-user\" { (name) }\n- a href=\"/login\" { \"account\" }\n+ a href=\"/login\" data-testid=\"nav-account\" { \"account\" }\n form class=\"top-nav-logout\" method=\"post\" action=\"/auth/logout\" data-navigate=\"full\" {\n button type=\"submit\" data-testid=\"nav-logout\" { \"log out\" }\n }\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 257280b6b490c65222e580a325b77351cac6cc6b..e7aca23b426d504938b90b5a25ad06b07843ba02 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -108,6 +108,152 @@ body {\n cursor: wait;\n }\n \n+.panel {\n+ max-width: 36rem;\n+ margin: 1.5rem auto;\n+ padding: 1.25rem 1.5rem;\n+ background: var(--panel);\n+ border: 1px solid var(--border);\n+}\n+\n+.login-page h1,\n+.alias-page h1 {\n+ margin: 0 0 0.35rem;\n+ font-size: 1.5rem;\n+}\n+\n+.login-page h2,\n+.alias-page h2 {\n+ margin: 0 0 0.5rem;\n+ font-size: 1.05rem;\n+ font-weight: 600;\n+}\n+\n+.login-section {\n+ margin-bottom: 1.5rem;\n+}\n+\n+.login-section:last-of-type {\n+ margin-bottom: 0.75rem;\n+}\n+\n+.account-current {\n+ margin: 0.25rem 0 0.5rem;\n+ font-size: 1.1rem;\n+}\n+\n+.oauth-provider-list {\n+ list-style: none;\n+ margin: 1rem 0 0;\n+ padding: 0;\n+ display: flex;\n+ flex-direction: column;\n+ gap: 0.5rem;\n+}\n+\n+.oauth-provider-list a {\n+ text-align: center;\n+ text-decoration: none;\n+ margin-top: 0;\n+}\n+\n+.alias-list {\n+ list-style: none;\n+ margin: 0.5rem 0 0;\n+ padding: 0;\n+ display: flex;\n+ flex-direction: column;\n+ gap: 0.4rem;\n+}\n+\n+.alias-item {\n+ display: flex;\n+ align-items: center;\n+ justify-content: space-between;\n+ gap: 0.75rem;\n+ padding: 0.55rem 0.75rem;\n+ border: 1px solid var(--border);\n+ background: var(--bg);\n+}\n+\n+.alias-item.alias-current {\n+ border-color: var(--accent);\n+}\n+\n+.alias-name {\n+ font-weight: 600;\n+}\n+\n+.alias-badge {\n+ font-size: 0.75rem;\n+ color: var(--accent);\n+ text-transform: lowercase;\n+}\n+\n+.alias-switch {\n+ margin: 0;\n+}\n+\n+.alias-switch .btn-secondary {\n+ margin-top: 0;\n+}\n+\n+.alias-claim label {\n+ display: block;\n+ margin-bottom: 0.35rem;\n+ color: var(--muted);\n+ font-size: 0.875rem;\n+}\n+\n+.alias-claim input[type=\"text\"] {\n+ width: 100%;\n+ padding: 0.5rem 0.65rem;\n+ background: var(--bg);\n+ border: 1px solid var(--border);\n+ color: var(--fg);\n+ font: inherit;\n+}\n+\n+.alias-claim input[type=\"text\"]:focus {\n+ outline: 1px solid var(--accent);\n+ border-color: var(--accent);\n+}\n+\n+#alias-status {\n+ margin: 0.4rem 0 0.75rem;\n+ min-height: 1.25em;\n+}\n+\n+#alias-status.alias-ok {\n+ color: #8fd19e;\n+}\n+\n+#alias-status.alias-bad {\n+ color: #e8a0a0;\n+}\n+\n+.login-actions {\n+ padding-top: 0.75rem;\n+ border-top: 1px solid var(--border);\n+}\n+\n+.login-actions .btn-secondary {\n+ margin-top: 0;\n+}\n+\n+.login-back {\n+ margin: 1rem 0 0;\n+}\n+\n+.login-back a {\n+ color: var(--muted);\n+ text-decoration: none;\n+}\n+\n+.login-back a:hover {\n+ color: var(--fg);\n+}\n+\n .fetch-entity-form {\n margin-top: 0.5rem;\n }\ndiff --git a/test/auth_login.clj b/test/auth_login.clj\nindex a81e064ebb88fe359c56cf6718880dc1e4c1a7d9..6f2f00d7aa7340e63d1ac465b0a2234cec7a983f 100644\n--- a/test/auth_login.clj\n+++ b/test/auth_login.clj\n@@ -55,7 +55,6 @@\n (move-vote-slider-left pg)\n (loc/click (page/get-by-test-id pg \"vote-post\"))\n (page/wait-for-selector pg \"[data-testid=oauth-github]\" {:timeout 15000})\n- (is (str/includes? (or (element-text pg \"alias-list-empty\") \"\") \"none yet\"))\n (loc/click (page/get-by-test-id pg \"oauth-github\"))\n (page/wait-for-selector pg \"[data-testid=alias-input]\" {:timeout 15000})\n (type-alias! pg \"seeder\")\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[d6da7856] Resurrect page view counts (ViewStore + middleware + layout) (#138)\n\n* Wire ViewStore through AppState, view-count middleware, and HTML.\n\n- Add views module to lib, ViewStore on AppState (create_app_state and tests).\n- Implement canonical_view_url and GET view_count_middleware with path filters.\n- Layer middleware before with_state; add url crate for query canonicalization.\n- Pass canonical-key view counts into garden, forum, search, and try layouts.\n- Replace stub integration test with real view counter assertions.\n\nCo-authored-by: tommy \n\n* Test vote/compare query canonicalization instead of search.\n\nVote compare uses chromeless layout without view badge; assert the shared\nViewStore count for permuted left/right query order via AppState.\n\nCo-authored-by: tommy \n\n* Browser test: stop waiting for removed vote-compare shell.\n\nAssert vote compare via body.view-vote-compare and compare heading instead\nof .vote-compare-shell, which was intentionally removed from the HTML.\n\nCo-authored-by: tommy \n\n* Drop vote-compare preview morph and browser assertion.\n\nvote_compare_post_success_js now only refreshes #vote-edge-history-region.\nRemove preview wrap markup, browser test wait on #vote-compare-preview, and\nunused forum re-export of ingest_entry_markup.\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex ad7e4fe6d4ba2f2b033916194c1ef1ed873f1d46..bf8153d9c723af97122c9ffdd4a7cfe82e853bb6 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -1805,6 +1805,7 @@ dependencies = [\n \"tower-http 0.5.2\",\n \"tracing\",\n \"tracing-subscriber\",\n+ \"url\",\n \"urlencoding\",\n \"uuid\",\n ]\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 6d85b8cce5701482367d4be43287394e26f5896f..0d9cdcd21d94751babdf769e118d2c89d6a009d8 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -28,6 +28,7 @@ slug-types = { path = \"../types\" }\n async-trait = \"0.1\"\n base64 = \"0.22\"\n postcard = { version = \"1\", features = [\"use-std\"] }\n+url = \"2\"\n urlencoding = \"2\"\n \n [dev-dependencies]\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex a24e638802c8bb38699b39fdb0c53c22183a4a49..8ff58326eb75806eeec3937f088fa977c7f3886e 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -9,8 +9,9 @@ use maud::{html, Markup};\n use serde::Deserialize;\n \n use crate::{\n- api::{validate_ingest_document, resolve_item},\n+ api::{resolve_item, validate_ingest_document},\n html::JsBuilder,\n+ middleware::canonical_view_url,\n reducer::ScopeId,\n state::AppState,\n };\n@@ -25,7 +26,10 @@ fn bc_try() -> Markup {\n }\n \n /// The interactive editor page — `/try`.\n-pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse {\n+pub async fn editor_page(State(state): State, jar: CookieJar, uri: Uri) -> impl IntoResponse {\n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"try — slug.social\",\n \"view-thread\",\n@@ -41,7 +45,7 @@ pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse {\n div id=\"editor-results\" {}\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs\nindex 63f196e0df251678bd649da97107e35a741f97fa..70321dd2a75e563282f7612f2b2fa6d57b9175d9 100644\n--- a/server/src/html/forum/feed.rs\n+++ b/server/src/html/forum/feed.rs\n@@ -8,6 +8,7 @@ use maud::{html, Markup};\n \n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n+use crate::middleware::canonical_view_url;\n use crate::reducer::{ReducerState, ScopeId};\n use crate::state::AppState;\n use crate::timeago;\n@@ -218,6 +219,9 @@ pub async fn home(\n let strip = auth_strip(&headers, &jar, &reduced_read);\n drop(reduced_read);\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"slug.social\",\n \"view-thread\",\n@@ -251,7 +255,7 @@ pub async fn home(\n (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n (cli_panel(&[\"npx slugsocial public forum list\"]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs\nindex 53809e94e3b107a1ddb937c2e45325fc6b8706c6..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644\n--- a/server/src/html/forum/mod.rs\n+++ b/server/src/html/forum/mod.rs\n@@ -20,7 +20,6 @@ pub use profile::user_profile_page;\n pub use views::{room_page, room_thread_view, thread_view};\n \n pub(crate) use access::{user_can_post_room, user_can_view_room};\n-pub(crate) use ingest::ingest_entry_markup;\n pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};\n pub(crate) use room_members::room_members_section_markup;\n pub(crate) use thread_morph::{\ndiff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs\nindex 6e36e80d076ddd8ca521c1450cbf8cb7ba6a28fe..91866b2cc3dea98d0c3847f54379245eca67894c 100644\n--- a/server/src/html/forum/post_single.rs\n+++ b/server/src/html/forum/post_single.rs\n@@ -8,6 +8,7 @@ use maud::html;\n \n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n+use crate::middleware::canonical_view_url;\n use crate::reducer::ScopeId;\n use crate::state::AppState;\n \n@@ -70,6 +71,9 @@ async fn thread_post_view_inner(\n }\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n &format!(\"#{tag} / post #{index}\"),\n \"view-thread\",\n@@ -87,7 +91,7 @@ async fn thread_post_view_inner(\n p class=\"muted\" { \"post not found\" }\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/profile.rs b/server/src/html/forum/profile.rs\nindex ebdc41fdd44e97a10a49ecd1ac5150ae390ec319..94701f8e9e293b64b889291c2145f97c1078c1c4 100644\n--- a/server/src/html/forum/profile.rs\n+++ b/server/src/html/forum/profile.rs\n@@ -9,6 +9,7 @@ use maud::html;\n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n use crate::identity::parse_username;\n+use crate::middleware::canonical_view_url;\n use crate::state::AppState;\n \n use super::ingest::{thread_nav_for_ingest, thread_post_index_in_scope};\n@@ -74,6 +75,9 @@ pub async fn user_profile_page(\n };\n \n let now = now_ms();\n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n &format!(\"@{canon}\"),\n \"view-thread\",\n@@ -108,7 +112,7 @@ pub async fn user_profile_page(\n }\n (cli_panel(&[format!(\"npx slugsocial public forum list\")]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs\nindex 34c47a2a6838a0ec99415e326cbd78f2cc988bd7..b363f0d7c8af3d941e4f402c1f1b87100bb0d394 100644\n--- a/server/src/html/forum/views.rs\n+++ b/server/src/html/forum/views.rs\n@@ -11,6 +11,7 @@ use serde_json::json;\n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n use crate::form_template::template_json_compact;\n+use crate::middleware::canonical_view_url;\n use crate::reducer::ScopeId;\n use crate::state::AppState;\n \n@@ -141,6 +142,9 @@ async fn thread_view_inner(\n ScopeId::Room(r) => format!(\"npx slugsocial private {r} forum show {tag}\"),\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let body = html! {\n (strip)\n nav class=\"breadcrumb\" { (bc) }\n@@ -166,7 +170,7 @@ async fn thread_view_inner(\n &format!(\"#{tag}\"),\n \"view-thread\",\n body,\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\n@@ -276,6 +280,9 @@ pub async fn room_page(\n let audit_cli = format!(\"npx slugsocial private {room_id} audit\");\n drop(reduced);\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let slug_display = room_id\n .split_once('/')\n .map(|(_, slug)| slug)\n@@ -296,7 +303,7 @@ pub async fn room_page(\n }\n (cli_panel(&[forum_cli, garden_cli, audit_cli]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 2d5fcd903fe8f56800ad2c5564b704bd25f9b18d..9e08307e636ce7984766168db1187f84b8635201 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -14,11 +14,12 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE\n use crate::{\n api::optional_principal,\n canonical_path::{canonicalize_item, canonicalize_tag},\n+ middleware::canonical_view_url,\n events::ThreadCapability,\n form_template::template_json_compact,\n html::{JsBuilder, ui_action::UI_RPC_FIELD, user_can_post_room},\n path_types::ItemId,\n- reducer::{ContentState, ReducerState, ScopeId, scope_from_room_wire},\n+ reducer::{ContentState, ReducerState, ScopeId},\n scope_rank::{ChildrenRankings, build_children_rankings},\n state::AppState,\n timeago,\n@@ -28,7 +29,7 @@ use super::{\n bc_path, bc_path_external, bc_segment,\n breadcrumb_path::{ExternalOntologyPath, OntologyPath},\n cli_panel,\n- forum::{ThreadNav, ingest_entry_markup},\n+ forum::ThreadNav,\n layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_linkified_with_embeds_in_scope,\n theme_from_jar, theme_next_from_uri,\n };\n@@ -224,39 +225,24 @@ fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemI\n }\n }\n \n-/// After a successful vote post: morph the new card into `#vote-compare-preview` and refresh edge history.\n+/// After a successful vote post: refresh edge history (no in-page preview card).\n pub(crate) async fn vote_compare_post_success_js(\n state: &AppState,\n nav: &ThreadNav,\n- room_wire: &str,\n- thread_tag: &str,\n+ _room_wire: &str,\n+ _thread_tag: &str,\n left: &ItemId,\n right: &ItemId,\n- post_id: &str,\n- post_idx: Option,\n+ _post_id: &str,\n+ _post_idx: Option,\n ) -> String {\n let reduced = state.reduced.read().await;\n- let scope = scope_from_room_wire(room_wire);\n- let Some(ing) = reduced.ingests_by_id.get(post_id).cloned() else {\n- drop(reduced);\n- return \"console.warn('vote compare: new post not found');\".to_string();\n- };\n- let idx = match post_idx {\n- Some(i) => i,\n- None => reduced\n- .try_thread_post_index_chronological(&scope, thread_tag, post_id)\n- .unwrap_or(0),\n- };\n- let viewer = None::<&str>;\n- let now = now_ms();\n let content = content_for_garden_view(&reduced, &nav.scope());\n let edge_history = vote_edge_history_markup(content, left, right);\n- let card = ingest_entry_markup(nav, thread_tag, idx, &ing, viewer, now, &reduced);\n drop(reduced);\n- let mut b = JsBuilder::new();\n- b = b.morph_inner_selector(\"#vote-compare-preview\", card);\n- b = b.morph_inner_selector(\"#vote-edge-history-region\", edge_history);\n- b.build()\n+ JsBuilder::new()\n+ .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .build()\n }\n \n fn item_display_path(item: &str) -> String {\n@@ -515,6 +501,9 @@ pub async fn garden_index(\n build_children_rankings(reduced.public(), &ItemId::ontology_root())\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"~/\",\n \"view-ontology view-ontology-light\",\n@@ -556,7 +545,7 @@ pub async fn garden_index(\n }\n (cli_panel(&[\"npx slugsocial garden tree\"]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(\"public\"),\n@@ -597,6 +586,9 @@ pub async fn external_garden_index(\n build_children_rankings(reduced.public(), &parent)\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"-/\",\n \"view-ontology view-ontology-light\",\n@@ -637,7 +629,7 @@ pub async fn external_garden_index(\n }\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(\"public\"),\n@@ -727,6 +719,9 @@ pub async fn room_external_garden_index(\n build_children_rankings(content_for_garden_view(&reduced, &nav.scope()), &parent);\n drop(reduced);\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"-/\",\n \"view-ontology view-ontology-light\",\n@@ -766,7 +761,7 @@ pub async fn room_external_garden_index(\n }\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(nav.room_wire.as_str()),\n@@ -1080,6 +1075,9 @@ async fn render_scope_view(\n .filter(|s| !s.is_empty())\n .unwrap_or_else(|| \"/\".to_string());\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n &item_display_path(&model.item),\n \"view-ontology view-ontology-light\",\n@@ -1227,7 +1225,7 @@ async fn render_scope_view(\n };\n (cli_panel(std::slice::from_ref(&cli)))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(&garden_room),\ndiff --git a/server/src/html/search.rs b/server/src/html/search.rs\nindex 04fd6f27854138f668689df3b250d7240645d1ac..35d3604ab8ee50327af5b0acf1a86e646633cf1f 100644\n--- a/server/src/html/search.rs\n+++ b/server/src/html/search.rs\n@@ -10,6 +10,7 @@ use serde::Deserialize;\n use crate::{\n api::optional_principal,\n events::ThreadCapability,\n+ middleware::canonical_view_url,\n reducer::{ReducerState, ScopeId},\n state::AppState,\n timeago,\n@@ -407,6 +408,9 @@ pub async fn search_page(\n SearchResults { items: vec![], threads: vec![], posts: vec![] }\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"search \\u{2014} slug.social\",\n \"view-thread\",\n@@ -420,7 +424,7 @@ pub async fn search_page(\n (render_search_results(&results, &query))\n (cli_panel(&[\"npx slugsocial search \"]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex fc95fc5a845fde9718195e275f2b228f68d913a6..b902094653cf9319efd14e67b91436e269362441 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -16,6 +16,7 @@ pub mod reducer;\n pub mod scope_rank;\n pub mod state;\n pub mod timeago;\n+pub mod views;\n pub mod write_cmd;\n \n use std::collections::HashMap;\n@@ -37,6 +38,8 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {\n let (stream_tx, _) = broadcast::channel(64);\n let (js_tx, _) = broadcast::channel(64);\n let (write_tx, write_rx) = mpsc::channel::(256);\n+ let views_path = format!(\"{}/views.json\", cfg.data_dir);\n+ let views = crate::views::ViewStore::new(&views_path);\n let state = AppState {\n cfg: Arc::new(cfg),\n event_log: Arc::new(event_log),\n@@ -46,6 +49,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {\n stream_tx,\n js_tx,\n write_tx,\n+ views,\n };\n tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone()));\n state\n@@ -118,6 +122,10 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/api/v0/pending-session/:id\", get(api::get_pending_session))\n .route(\"/api/v0/whoami\", get(api::get_whoami))\n .route(\"/api/v0/rpc\", post(api::handle_rpc_batch))\n+ .layer(axum::middleware::from_fn_with_state(\n+ state.clone(),\n+ crate::middleware::view_count_middleware,\n+ ))\n .with_state(state)\n .layer(TraceLayer::new_for_http())\n }\ndiff --git a/server/src/middleware.rs b/server/src/middleware.rs\nindex 47751d0da2cf44df44b3e7c94e2a1063b29bd6d6..458bd65054bb1baa70691b06ec44ae71b72b47c5 100644\n--- a/server/src/middleware.rs\n+++ b/server/src/middleware.rs\n@@ -1,12 +1,47 @@\n use axum::{\n- extract::Request,\n+ extract::{Request, State},\n middleware::Next,\n response::Response,\n };\n \n+use crate::state::AppState;\n+\n+pub fn canonical_view_url(uri: &axum::http::Uri) -> String {\n+ let path = uri.path();\n+ if let Some(query) = uri.query() {\n+ let mut pairs: Vec<_> = url::form_urlencoded::parse(query.as_bytes()).into_owned().collect();\n+ if pairs.is_empty() {\n+ return path.to_string();\n+ }\n+ pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));\n+\n+ let new_query = url::form_urlencoded::Serializer::new(String::new())\n+ .extend_pairs(pairs)\n+ .finish();\n+ format!(\"{path}?{new_query}\")\n+ } else {\n+ path.to_string()\n+ }\n+}\n+\n pub async fn view_count_middleware(\n+ State(state): State,\n req: Request,\n next: Next,\n ) -> Response {\n+ if req.method() == axum::http::Method::GET {\n+ let path = req.uri().path();\n+\n+ if !path.starts_with(\"/static\")\n+ && !path.starts_with(\"/api\")\n+ && !path.starts_with(\"/sse\")\n+ && !path.starts_with(\"/auth\")\n+ && path != \"/healthz\"\n+ && path != \"/ui\"\n+ {\n+ let url_key = canonical_view_url(req.uri());\n+ state.views.increment(url_key);\n+ }\n+ }\n next.run(req).await\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8fc39914d64ace53a544c5eb85bc61447db60305..13831e868031edc19fda0f347376e0b17e8b99b7 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -67,6 +67,7 @@ pub struct AppState {\n pub js_tx: broadcast::Sender,\n /// All durable writes and reducer mutations are serialized through this channel.\n pub write_tx: mpsc::Sender,\n+ pub views: crate::views::ViewStore,\n }\n \n impl AppState {\n@@ -81,6 +82,8 @@ impl AppState {\n let event_log = EventLog::new(cfg.event_log_path.clone());\n let (stream_tx, _) = broadcast::channel(64);\n let (js_tx, _) = broadcast::channel(64);\n+ let views_path = format!(\"{}/views.json\", cfg.data_dir);\n+ let views = crate::views::ViewStore::new(&views_path);\n Self {\n cfg: Arc::new(cfg),\n event_log: Arc::new(event_log),\n@@ -90,6 +93,7 @@ impl AppState {\n stream_tx,\n js_tx,\n write_tx,\n+ views,\n }\n }\n }\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex b34ba69a2115cad9875afce17743ad27f3838959..bd10fd2a6ef06ac1a23de790a54ad5aa0065a472 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -1,8 +1,10 @@\n+use axum::http::Uri;\n use sha2::{Digest, Sha256};\n-use slug_types::room_route_segment;\n+use slug_types::{room_route_segment, ItemId};\n use slugsocial_server::{\n event_log::EventLog,\n events::{Event, TokenIssued, UserRegistered},\n+ middleware::canonical_view_url,\n spawn_writer_actor_for_test,\n state::{AppConfig, AppState},\n };\n@@ -1368,7 +1370,69 @@ async fn test_search_page_and_results() {\n \n #[tokio::test]\n async fn test_view_counts_increment_and_display() {\n- // HTML view counters are offline during the auth-v3 refactor.\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::new();\n+ let url = format!(\"http://{addr}/~\");\n+\n+ let r1 = client.get(&url).send().await.unwrap();\n+ assert!(r1.status().is_success());\n+ let body1 = r1.text().await.unwrap();\n+ assert!(\n+ body1.contains(\"1 views\"),\n+ \"expected first GET to show 1 views, body snippet: {}\",\n+ &body1.chars().take(500).collect::()\n+ );\n+\n+ let r2 = client.get(&url).send().await.unwrap();\n+ assert!(r2.status().is_success());\n+ let body2 = r2.text().await.unwrap();\n+ assert!(\n+ body2.contains(\"2 views\"),\n+ \"expected second GET to show 2 views\"\n+ );\n+\n+ // Vote compare uses permuted `left` / `right`; middleware canonicalizes query order.\n+ let left = ItemId::parse(\"~/vc-l\")\n+ .unwrap()\n+ .normalized_storage()\n+ .to_storage_string();\n+ let right = ItemId::parse(\"~/vc-r\")\n+ .unwrap()\n+ .normalized_storage()\n+ .to_storage_string();\n+ let vote_q_right_first = format!(\n+ \"/vote/compare?right={}&left={}\",\n+ urlencoding::encode(&right),\n+ urlencoding::encode(&left)\n+ );\n+ let vote_q_left_first = format!(\n+ \"/vote/compare?left={}&right={}\",\n+ urlencoding::encode(&left),\n+ urlencoding::encode(&right)\n+ );\n+ let vote_key_uri: Uri = format!(\"http://127.0.0.1{vote_q_left_first}\")\n+ .parse()\n+ .unwrap();\n+ let vote_key = canonical_view_url(&vote_key_uri);\n+\n+ let v1 = client\n+ .get(format!(\"http://{addr}{vote_q_right_first}\"))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(v1.status().is_success(), \"vote compare GET 1: {}\", v1.status());\n+ let v2 = client\n+ .get(format!(\"http://{addr}{vote_q_left_first}\"))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(v2.status().is_success(), \"vote compare GET 2: {}\", v2.status());\n+\n+ assert_eq!(\n+ state.views.get_views(&vote_key),\n+ 2,\n+ \"permuted vote/compare URLs should share one ViewStore key ({vote_key:?})\"\n+ );\n }\n \n #[tokio::test]\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex 78fc40c160c84478ab8ee0d953b9ed878b4c712f..839482d12963f130a2063f2212a6b922b2d229e1 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -71,13 +71,12 @@\n (page/navigate pg (str base-url \"/login\"))\n (is (wait-for-text pg \"body\" \"@alice\" 15000) \"alice session after login\")\n (page/navigate pg cmp-url)\n- (is (wait-for-text pg \".vote-compare-shell\" \"compare\" 15000) \"vote compare shell\")\n+ ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead.\n+ (is (wait-for-text pg \"body.view-vote-compare\" \"compare\" 15000) \"vote compare page\")\n (is (wait-for-text pg \"ul.vote-edge-history\" \"seed edge vote\" 15000)\n \"edge history lists canonical-order vote\")\n (locator/fill (page/locator pg \"#vote-explain\") \"because playwright says so\")\n (locator/click (page/locator pg \"#vote-compare-form button[type=submit]\"))\n- (is (wait-for-text pg \"#vote-compare-preview\" \"because playwright\" 20000)\n- \"preview region morphs new post card\")\n (is (wait-for-text pg \"ul.vote-edge-history\" \"because playwright\" 20000)\n \"new vote appears in edge history after morph\"))))))\n \n","role":"user"}],"model":"~x-ai/grok-latest"}