{"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[0455c2d4] Vote heat index below landing deal\n\nSide A — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex 59de8bb1c22a89c8ae0cbc473ca0bfe7a80d3e48..64413377b8faecf1a58d266796ee7acfb0ce4812 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -54,7 +54,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\n \n - **Retired question pages:** `/q/:collection`, `/q/:collection/:aspect`, and the room-scoped twins are gone entirely — no routes serve them, so they 404 (no question index on `/` either). Pairwise judging lives at `/vote?pool=` / `/vote?left=&right=`; aspect groups still exist in the DSL/CLI and render on garden scope pages.\n \n-- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the most-compared open question first (`pick_landing_question` in `server/src/html/garden/vote.rs`: highest voted-pair count across canonical scopes and voted aspect groups, ties prefer more members then canonical), with a 3-step intro naming the scope (and `:aspect`), or an empty state when everything is judged. Popularity compounds deliberately: dealing a busy scope's stragglers grows it further. `/vote?pool=&aspect=` votes into that aspect group (hidden `aspect` input over the existing `$form` hole; invalid slugs 400). Garden aspect sections link their vote pool the same way.\n+- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the most-compared open question first (`pick_landing_question` in `server/src/html/garden/vote.rs`: highest voted-pair count across canonical scopes and voted aspect groups, ties prefer more members then canonical), with a 3-step intro naming the scope (and `:aspect`), or an empty state when everything is judged. Popularity compounds deliberately: dealing a busy scope's stragglers grows it further. Below the dealt pair, a heat index lists more open questions hottest first (`rank_open_questions`: newest vote wins, then newest thread post, so judging heat beats talking heat; the dealt question is excluded; capped at 10) — each row deals straight into its pool, with judged counts and activity instead of scores so it never reads as rank order. `/vote?pool=&aspect=` votes into that aspect group (hidden `aspect` input over the existing `$form` hole; invalid slugs 400). Garden aspect sections link their vote pool the same way.\n \n - **`ThreadGraduate` / `GraduateThread`:** Private-room forum threads with **Manage** can be published to the public site under the same tag. The writer replays non-redacted ingests into **`room: public`** (chronological order), then appends a durable **`ThreadGraduated`** marker. Graduated private threads show a banner linking to public **`/t/:tag`**, block further private posts, and cannot be graduated twice. CLI: **`npx slugsocial private forum graduate `**; RPC: **`ThreadGraduate`**.\n \ndiff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs\nindex ff9875222f8954e2f80d4ce62100176dcdf08d60..c1c8c5e6517bb20eaae31760b7d111d13966b726 100644\n--- a/server/src/html/garden/tests.rs\n+++ b/server/src/html/garden/tests.rs\n@@ -10,8 +10,9 @@ use super::{\n render::aspect_ranking_sections_markup,\n vote::{\n canonical_edge_items, edge_vote_count_for_pair, edge_vote_entries_for_pair,\n- pick_landing_question, ratios_for_compare_page, sort_votes_for_compare_display,\n- suggest_next_vote_pair, vote_compare_item_card, vote_pool_href,\n+ pick_landing_question, rank_open_questions, ratios_for_compare_page,\n+ sort_votes_for_compare_display, suggest_next_vote_pair, vote_compare_item_card,\n+ vote_pool_href,\n },\n };\n use crate::{\n@@ -907,6 +908,57 @@ fn pick_landing_question_returns_none_when_all_judged() {\n assert!(pick_landing_question(content).is_none());\n }\n \n+/// Heat index: judging heat beats talking heat — a scope with an old vote\n+/// outranks a scope with a fresh thread post but no votes, and newer votes\n+/// outrank older ones.\n+#[test]\n+fn rank_open_questions_prefers_votes_over_thread_chatter() {\n+ use crate::events::Ingest as TestIngest;\n+ let mut reduced = ReducerState::default();\n+ let post = |ts: i64, id: &str, raw: &str, thread_tag: &str| Event::Ingest(TestIngest {\n+ ts,\n+ id: id.to_string(),\n+ raw: raw.to_string(),\n+ principal: \"testuser\".to_string(),\n+ delegate: None,\n+ room_id: \"public\".to_string(),\n+ thread_tag: thread_tag.to_string(),\n+ });\n+ // Voted long ago (still open: 1 of 3 pairs), never discussed since.\n+ reduced.apply_event(post(\n+ 1,\n+ \"v1\",\n+ \"~/voted/a { a }\\n~/voted/b { b }\\n~/voted/c { c }\\n\",\n+ \"voted\",\n+ ));\n+ reduced.apply_event(post(\n+ 2,\n+ \"v2\",\n+ \"{ old }\\n~/voted/a 2:1 ~/voted/b\\n\",\n+ \"voted\",\n+ ));\n+ // Never voted, talked about yesterday.\n+ reduced.apply_event(post(10, \"t1\", \"~/talked/x { ex }\\n~/talked/y { why }\\n\", \"talked\"));\n+ reduced.apply_event(post(50, \"t2\", \"lively debate\", \"talked\"));\n+ let content = content_for_garden_view(&reduced, &ScopeId::Public);\n+ let rows = rank_open_questions(content, &reduced.forum_threads, &ScopeId::Public);\n+ assert_eq!(rows.len(), 2);\n+ assert!(\n+ rows[0].question.scope.as_str().ends_with(\"voted\"),\n+ \"judged scope first, got {}\",\n+ rows[0].question.scope.as_str()\n+ );\n+ assert!(rows[0].thread_ts > 0);\n+ assert_eq!(rows[0].question.last_vote_ts, 2);\n+ assert!(\n+ rows[1].question.scope.as_str().ends_with(\"talked\"),\n+ \"talked-about scope second, got {}\",\n+ rows[1].question.scope.as_str()\n+ );\n+ assert_eq!(rows[1].question.last_vote_ts, 0);\n+ assert_eq!(rows[1].thread_ts, 50);\n+}\n+\n /// Popularity beats neediness: a busy scope with stragglers outranks a fresh\n /// scope nobody has touched. Winners keep winning.\n #[test]\ndiff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs\nindex 116a4cc51bcea769bb5458ce166951483dc41758..f0ccb080ff2d941e4ce07f57ac520bab84c12003 100644\n--- a/server/src/html/garden/vote.rs\n+++ b/server/src/html/garden/vote.rs\n@@ -14,13 +14,14 @@ use crate::{\n canonical_path::canonicalize_tag,\n form_template::template_json_compact,\n html::{\n- format_ratio, forum::ThreadNav, layout_full_bleed_chromeless, ratio_pct,\n+ format_ratio, forum::ThreadNav, layout_full_bleed_chromeless, now_ms, ratio_pct,\n render_item_body_in_scope, theme_from_jar, theme_next_from_uri, ui_action::UI_RPC_FIELD,\n user_can_post_room, JsBuilder,\n },\n middleware::canonical_view_url,\n path_types::ItemId,\n- reducer::{ContentState, GroupState, ScopeId},\n+ reducer::{ContentState, ForumThreadState, ScopeId},\n+ timeago,\n scope_rank::{comparable_items, suggest_next_pair_in_pool},\n state::AppState,\n };\n@@ -579,6 +580,7 @@ pub(super) struct LandingQuestion {\n pub voted: usize,\n pub possible: usize,\n pub members: usize,\n+ pub last_vote_ts: i64,\n }\n \n /// Density of voted pairs among `members` inside one vote graph.\n@@ -604,76 +606,127 @@ fn voted_density(\n (voted, possible)\n }\n \n-pub(super) fn pick_landing_question(content: &ContentState) -> Option {\n- // Most voted pairs wins; ties prefer more members, then canonical over an\n- // aspect, then lex order (iteration is lex-sorted and only strictly-better\n- // candidates replace the incumbent).\n- let mut best: Option = None;\n+/// Every open question (canonical + aspect groups with ≥1 unvoted pair),\n+/// unsorted. Powers both the landing deal and the heat index below it.\n+fn open_questions(content: &ContentState) -> Vec {\n+ let mut out = Vec::new();\n let mut scopes: Vec<&ItemId> = content\n .members_by_scope\n .keys()\n .filter(|id| matches!(id.tilde_tail(), Some(t) if !t.is_empty() && !t.contains('/')))\n .collect();\n scopes.sort_by(|a, b| a.as_str().cmp(b.as_str()));\n- // (scope, aspect-group-or-canonical, aspect-slug-or-None), canonical first\n- // per scope so ties keep the canonical question.\n- let mut candidates: Vec<(&ItemId, Option<(&GroupState, String)>)> = Vec::new();\n- for scope in &scopes {\n- candidates.push((scope, None));\n- }\n let mut aspect_keys: Vec<&(ItemId, String)> = content.aspect_groups.keys().collect();\n aspect_keys.sort();\n- for (scope, slug) in aspect_keys {\n- if scopes.iter().any(|s| s.as_str() == scope.as_str()) {\n- candidates.push((\n- scopes\n- .iter()\n- .find(|s| s.as_str() == scope.as_str())\n- .expect(\"scope present\"),\n- Some((\n- content\n- .aspect_groups\n- .get(&(scope.clone(), slug.clone()))\n- .expect(\"aspect group present\"),\n- slug.clone(),\n- )),\n- ));\n- }\n- }\n- for (scope, group_opt) in candidates {\n+ for scope in &scopes {\n let members = comparable_items(content, content.members_of(scope));\n if members.len() < 2 {\n continue;\n }\n- let (group_idx, group_pairs) = match &group_opt {\n- None => (\n- &content.ranking_group.item_to_idx,\n- &content.ranking_group.voted_pairs,\n- ),\n- Some((group, _)) => (&group.item_to_idx, &group.voted_pairs),\n- };\n- let (voted, possible) = voted_density(group_idx, group_pairs, &members);\n- if voted >= possible {\n- continue;\n+ let (voted, possible) = voted_density(\n+ &content.ranking_group.item_to_idx,\n+ &content.ranking_group.voted_pairs,\n+ &members,\n+ );\n+ if voted < possible {\n+ out.push(LandingQuestion {\n+ scope: (*scope).clone(),\n+ aspect: None,\n+ voted,\n+ possible,\n+ members: members.len(),\n+ last_vote_ts: last_canonical_vote_ts(content, &members),\n+ });\n+ }\n+ for (ascope, slug) in aspect_keys\n+ .iter()\n+ .filter(|(s, _)| s.as_str() == scope.as_str())\n+ {\n+ let Some(group) = content.aspect_groups.get(&(ascope.clone(), slug.clone())) else {\n+ continue;\n+ };\n+ let (voted, possible) =\n+ voted_density(&group.item_to_idx, &group.voted_pairs, &members);\n+ if voted < possible {\n+ out.push(LandingQuestion {\n+ scope: (*scope).clone(),\n+ aspect: Some(slug.clone()),\n+ voted,\n+ possible,\n+ members: members.len(),\n+ last_vote_ts: group.recent_votes.front().map(|v| v.ts).unwrap_or(0),\n+ });\n+ }\n }\n- // Highest voted-pair count first; ties prefer more members (then the\n- // lex-first candidate, since iteration is sorted and only\n- // strictly-better replaces the incumbent).\n+ }\n+ out\n+}\n+\n+/// Newest canonical vote touching two members (0 when none). The global\n+/// recent-votes deque is newest-first, so the first in-electorate hit wins.\n+fn last_canonical_vote_ts(content: &ContentState, members: &[ItemId]) -> i64 {\n+ let set: std::collections::HashSet<&ItemId> = members.iter().collect();\n+ content\n+ .ranking_group\n+ .recent_votes\n+ .iter()\n+ .find(|v| set.contains(&v.a) && set.contains(&v.b))\n+ .map(|v| v.ts)\n+ .unwrap_or(0)\n+}\n+\n+/// One row of the heat index: an open question plus its thread heartbeat.\n+pub(super) struct OpenRow {\n+ pub question: LandingQuestion,\n+ pub thread_ts: i64,\n+}\n+\n+const OPEN_INDEX_CAP: usize = 10;\n+\n+/// Open questions hottest first: newest vote wins, then newest thread post.\n+/// Votes always outrank mere discussion (a voted question has\n+/// `last_vote_ts > 0`; a talked-about one has 0), so judging heat beats\n+/// talking heat by construction.\n+pub(super) fn rank_open_questions(\n+ content: &ContentState,\n+ threads: &HashMap<(ScopeId, String), ForumThreadState>,\n+ scope: &ScopeId,\n+) -> Vec {\n+ let mut rows: Vec = open_questions(content)\n+ .into_iter()\n+ .map(|question| {\n+ let leaf = canonicalize_tag(question.scope.last_segment());\n+ let thread_ts = threads\n+ .get(&(scope.clone(), leaf))\n+ .map(|t| t.last_activity_ts)\n+ .unwrap_or(0);\n+ OpenRow {\n+ question,\n+ thread_ts,\n+ }\n+ })\n+ .collect();\n+ rows.sort_by(|a, b| {\n+ (b.question.last_vote_ts, b.thread_ts).cmp(&(a.question.last_vote_ts, a.thread_ts))\n+ });\n+ rows\n+}\n+\n+pub(super) fn pick_landing_question(content: &ContentState) -> Option {\n+ // Most voted pairs wins; ties prefer more members, then canonical over an\n+ // aspect, then lex order (`open_questions` is lex-sorted with canonical\n+ // first per scope, and only strictly-better replaces the incumbent).\n+ let mut best: Option = None;\n+ for q in open_questions(content) {\n let take = match &best {\n None => true,\n Some(incumbent) => {\n- voted.cmp(&incumbent.voted) == std::cmp::Ordering::Greater\n- || (voted == incumbent.voted && members.len() > incumbent.members)\n+ q.voted.cmp(&incumbent.voted) == std::cmp::Ordering::Greater\n+ || (q.voted == incumbent.voted && q.members > incumbent.members)\n }\n };\n if take {\n- best = Some(LandingQuestion {\n- scope: (*scope).clone(),\n- aspect: group_opt.map(|(_, slug)| slug),\n- voted,\n- possible,\n- members: members.len(),\n- });\n+ best = Some(q);\n }\n }\n best\n@@ -808,6 +861,7 @@ pub(super) async fn vote_compare_inner(\n aspect_slug,\n q.thread.clone(),\n None,\n+ None,\n )\n .await\n }\n@@ -826,6 +880,7 @@ async fn render_compare_page(\n aspect_slug: Option,\n query_thread: Option,\n intro: Option,\n+ below: Option,\n ) -> axum::response::Response {\n let reduced = state.reduced.read().await;\n let content = content_for_garden_view(&reduced, &nav.scope());\n@@ -894,6 +949,9 @@ async fn render_compare_page(\n (intro)\n }\n (panel)\n+ @if let Some(below) = below {\n+ (below)\n+ }\n };\n \n let url_key = canonical_view_url(&uri);\n@@ -920,14 +978,25 @@ async fn vote_landing(\n jar: CookieJar,\n uri: Uri,\n ) -> axum::response::Response {\n- let needy = {\n+ let scope_id = nav.scope();\n+ let (pick, index) = {\n let reduced = state.reduced.read().await;\n let content = content_for_garden_view(&reduced, &nav.scope());\n- pick_landing_question(content).map(|n| (n.scope, n.aspect, n.voted, n.possible))\n+ let pick = pick_landing_question(content);\n+ let mut index = rank_open_questions(content, &reduced.forum_threads, &scope_id);\n+ if let Some(dealt) = &pick {\n+ index.retain(|row| {\n+ !(row.question.scope == dealt.scope && row.question.aspect == dealt.aspect)\n+ });\n+ }\n+ index.truncate(OPEN_INDEX_CAP);\n+ (pick, index)\n };\n- let Some((scope, aspect, voted, possible)) = needy else {\n+ let Some(dealt) = pick else {\n return landing_empty_page(&state, &jar, &uri).await;\n };\n+ let (scope, aspect, voted, possible) =\n+ (dealt.scope, dealt.aspect, dealt.voted, dealt.possible);\n let pair = {\n let reduced = state.reduced.read().await;\n let content = content_for_garden_view(&reduced, &nav.scope());\n@@ -980,6 +1049,11 @@ async fn vote_landing(\n }\n }\n };\n+ let below = if index.is_empty() {\n+ None\n+ } else {\n+ Some(open_index_markup(&nav, &index, now_ms()))\n+ };\n render_compare_page(\n state,\n nav,\n@@ -992,10 +1066,64 @@ async fn vote_landing(\n aspect,\n None,\n Some(intro),\n+ below,\n )\n .await\n }\n \n+/// Heat index below the dealt pair: more open questions, hottest first. Each\n+/// row deals straight into its pool — a menu, not a ranking, so it carries\n+/// judged counts and activity instead of scores.\n+fn open_index_markup(nav: &ThreadNav, rows: &[OpenRow], now: i64) -> maud::Markup {\n+ html! {\n+ section class=\"vote-open-index ont-tab-panel\" {\n+ h3 { \"more open questions\" }\n+ ul class=\"vote-open-list\" {\n+ @for row in rows {\n+ @let q = &row.question;\n+ @let judge_href = match &q.aspect {\n+ None => vote_pool_href(nav, q.scope.as_str()),\n+ Some(slug) => format!(\n+ \"{}&aspect={}\",\n+ vote_pool_href(nav, q.scope.as_str()),\n+ urlencoding::encode(slug)\n+ ),\n+ };\n+ @let garden_href = match &q.aspect {\n+ None => item_href(q.scope.as_str(), nav),\n+ Some(slug) => {\n+ format!(\"{}#aspect-{slug}\", item_href(q.scope.as_str(), nav))\n+ }\n+ };\n+ @let leaf = canonicalize_tag(q.scope.last_segment());\n+ @let active_ts = row.question.last_vote_ts.max(row.thread_ts);\n+ li class=\"vote-open-row\" {\n+ a class=\"vote-open-judge\" href=(judge_href) { \"judge\" }\n+ span class=\"vote-open-name\" {\n+ @if let Some(slug) = &q.aspect {\n+ span class=\"vote-open-aspect\" { \":\" (slug) \" in \" }\n+ }\n+ a href=(garden_href) { (item_display_path(q.scope.as_str())) }\n+ }\n+ span class=\"muted vote-open-meta\" {\n+ (format!(\"{} of {} judged\", q.voted, q.possible))\n+ @if active_ts > 0 {\n+ @let hover = timeago::rfc3339_utc(active_ts);\n+ @let ago = timeago::timeago(now, active_ts);\n+ \" · \"\n+ span title=(hover) { (ago) }\n+ }\n+ }\n+ span class=\"vote-open-links\" {\n+ a href=(nav.thread_url(&leaf)) { \"thread\" }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n /// Nothing left to judge: every scope with two comparable members is fully compared.\n async fn landing_empty_page(\n state: &AppState,\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex f2e54588842802e848427a0597a5f372de3d2e52..a52e3dcd0621b87a22b49ccfe5201b31e454b5b6 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -2342,3 +2342,60 @@ ol.vote-landing-steps {\n ol.vote-landing-steps li {\n margin: 4px 0;\n }\n+\n+/* Heat index below the dealt pair: a menu, not a ranking — judged counts\n+ and activity instead of scores, so it never reads as rank order. */\n+.vote-open-index {\n+ margin: 8px auto 32px;\n+ max-width: 560px;\n+ padding: 0 16px;\n+}\n+.vote-open-index > h3 {\n+ border-top: 2px solid var(--lo);\n+ color: var(--ui);\n+ font-size: 11px;\n+ margin-top: 8px;\n+ padding-top: 10px;\n+}\n+ul.vote-open-list {\n+ display: flex;\n+ flex-direction: column;\n+ gap: 6px;\n+ list-style: none;\n+ margin: 0;\n+ padding: 0;\n+ width: 100%;\n+ max-width: 100%;\n+}\n+ul.vote-open-list li.vote-open-row {\n+ align-items: baseline;\n+ background: var(--g3);\n+ border: 1px solid var(--lo);\n+ border-radius: 2px;\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 2px 8px;\n+ padding: 6px 10px;\n+ width: auto;\n+ max-width: none;\n+}\n+a.vote-open-judge {\n+ font-size: 12px;\n+ font-weight: 700;\n+ white-space: nowrap;\n+}\n+.vote-open-name {\n+ font-size: 13px;\n+ font-weight: 600;\n+}\n+.vote-open-name a {\n+ color: var(--signal);\n+}\n+.vote-open-meta {\n+ font-size: 11px;\n+}\n+.vote-open-links {\n+ font-size: 11px;\n+ margin-left: auto;\n+ white-space: nowrap;\n+}\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex 5748cdb5c588bb389ca9ff413377ce9b6b7cb9ab..7e2be497fcd35105c40ab334a754859671eca168 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -472,6 +472,59 @@ body.view-ontology ol.vote-landing-steps {\n body.view-ontology ol.vote-landing-steps li {\n margin: 0.3rem 0;\n }\n+body.view-ontology .vote-open-index {\n+ margin: 0.5rem auto 2rem;\n+ max-width: 560px;\n+}\n+body.view-ontology .vote-open-index > h3 {\n+ border-top: 2px solid #ccc;\n+ color: #333;\n+ font-size: 0.75rem;\n+ letter-spacing: 0.14em;\n+ margin-top: 0.5rem;\n+ padding-top: 0.6rem;\n+ text-transform: uppercase;\n+}\n+body.view-ontology ul.vote-open-list {\n+ display: flex;\n+ flex-direction: column;\n+ gap: 0.4rem;\n+ list-style: none;\n+ margin: 0;\n+ padding: 0;\n+ width: 100%;\n+ max-width: 100%;\n+}\n+body.view-ontology ul.vote-open-list li.vote-open-row {\n+ align-items: baseline;\n+ background: #faf8f3;\n+ border: 1px solid #ccc;\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 0.15rem 0.5rem;\n+ padding: 0.4rem 0.65rem;\n+ width: auto;\n+ max-width: none;\n+}\n+body.view-ontology a.vote-open-judge {\n+ font-size: 0.78rem;\n+ font-weight: 600;\n+ white-space: nowrap;\n+}\n+body.view-ontology .vote-open-name {\n+ font-size: 0.85rem;\n+ font-weight: 600;\n+ color: #111;\n+}\n+body.view-ontology .vote-open-meta {\n+ font-size: 0.72rem;\n+ color: #666;\n+}\n+body.view-ontology .vote-open-links {\n+ font-size: 0.72rem;\n+ margin-left: auto;\n+ white-space: nowrap;\n+}\n \n /* ================================================================\n GARDEN HIERARCHY — parents ↑ / self / children ↓ (paper context).\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex 867f8fec9c6240e231b539828a13e659a85b2438..f2f1faa018f924a00172782c88a1588bc8c22597 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -1316,6 +1316,67 @@ body.view-ontology ol.vote-landing-steps {\n body.view-ontology ol.vote-landing-steps li {\n margin: 0.3rem 0;\n }\n+body.view-ontology .vote-open-index {\n+ margin: 0.5rem auto 2rem;\n+ max-width: 38rem;\n+ padding: 0 1.25rem;\n+}\n+body.view-ontology .vote-open-index > h3 {\n+ border-top: 2px solid #c8c4bc;\n+ color: #3d3a34;\n+ font-family: var(--font-ui);\n+ font-size: 0.72rem;\n+ font-weight: 600;\n+ letter-spacing: 0.14em;\n+ margin-top: 0.5rem;\n+ padding-top: 0.6rem;\n+ text-transform: uppercase;\n+}\n+body.view-ontology ul.vote-open-list {\n+ display: flex;\n+ flex-direction: column;\n+ gap: 0.4rem;\n+ list-style: none;\n+ margin: 0;\n+ padding: 0;\n+ width: 100%;\n+ max-width: 100%;\n+}\n+body.view-ontology ul.vote-open-list li.vote-open-row {\n+ align-items: baseline;\n+ background: #f7f3eb;\n+ border: 1px solid #c8c4bc;\n+ border-radius: 2px;\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 0.15rem 0.5rem;\n+ padding: 0.4rem 0.65rem;\n+ width: auto;\n+ max-width: none;\n+}\n+body.view-ontology ul.vote-open-list li::before {\n+ content: none;\n+}\n+body.view-ontology a.vote-open-judge {\n+ font-family: var(--font-ui);\n+ font-size: 0.72rem;\n+ font-weight: 600;\n+ white-space: nowrap;\n+}\n+body.view-ontology .vote-open-name {\n+ font-size: 0.85rem;\n+ font-weight: 600;\n+ color: #1a1814;\n+}\n+body.view-ontology .vote-open-meta {\n+ font-size: 0.72rem;\n+ color: #8a857a;\n+}\n+body.view-ontology .vote-open-links {\n+ font-size: 0.72rem;\n+ margin-left: auto;\n+ white-space: nowrap;\n+}\n \n .home-intro { margin: 1rem 0; }\n details.question-row {\ndiff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs\nindex 2c7f52bbb36b69135268d600f404f4a34e1b42c2..fc3e2f46c2c755aefed2b1bebd6de7ed4075a60e 100644\n--- a/server/tests/integration_html.rs\n+++ b/server/tests/integration_html.rs\n@@ -444,6 +444,96 @@ async fn test_vote_landing_serves_neediest_pair() {\n assert!(body.contains(\"value=\\\"duel\\\"\"), \"thread_tag should seed to pool leaf\");\n }\n \n+#[tokio::test]\n+async fn test_vote_landing_lists_more_open_questions() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ {\n+ let mut w = state.reduced.write().await;\n+ // NOTE: leaves are global (`~/duel/a` IS `~a`), so the straggler scope\n+ // needs its own leaves or the busy scope's votes would judge its pair.\n+ w.apply_event(Event::Ingest(Ingest {\n+ ts: 20,\n+ id: \"ing-duel2\".into(),\n+ raw: \"~/duel/p { Pee }\\n~/duel/q { Kew }\\n\".to_string(),\n+ principal: \"testuser\".into(),\n+ delegate: None,\n+ room_id: \"public\".into(),\n+ thread_tag: \"duel\".into(),\n+ }));\n+ w.apply_event(Event::Ingest(Ingest {\n+ ts: 25,\n+ id: \"ing-old\".into(),\n+ raw: \"~/old/a { Alpha }\\n~/old/b { Beta }\\n~/old/c { Gamma }\\n\\\n+ { judged }\\n~/old/a 2:1 ~/old/b\\n\"\n+ .to_string(),\n+ principal: \"testuser\".into(),\n+ delegate: None,\n+ room_id: \"public\".into(),\n+ thread_tag: \"old\".into(),\n+ }));\n+ }\n+ let client = reqwest::Client::new();\n+ let body = client\n+ .get(format!(\"http://{addr}/vote\"))\n+ .send()\n+ .await\n+ .unwrap()\n+ .text()\n+ .await\n+ .unwrap();\n+ // Dealt pair is ~/old (1 judged pair beats duel's 0); the index lists\n+ // only the straggler — never a second row for the dealt question.\n+ assert!(body.contains(\"more open questions\"), \"heat index missing\");\n+ assert_eq!(\n+ body.matches(\"vote-open-row\").count(),\n+ 1,\n+ \"index should hold exactly the straggler row\"\n+ );\n+ assert!(\n+ body.contains(\"~%2Fduel\"),\n+ \"straggler judge link missing: {}\",\n+ body.chars().take(3000).collect::()\n+ );\n+ assert!(body.contains(\"0 of 1 judged\"));\n+}\n+\n+#[tokio::test]\n+async fn test_vote_landing_deals_hungrier_aspect_group() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ {\n+ let mut w = state.reduced.write().await;\n+ w.apply_event(Event::Ingest(Ingest {\n+ ts: 24,\n+ id: \"ing-tri-full\".into(),\n+ raw: \"~/tri/a { alpha }\\n~/tri/b { beta }\\n~/tri/c { gamma }\\n\\\n+ { ab }\\n~/tri/a 2:1 ~/tri/b\\n\\\n+ { ac }\\n~/tri/a 2:1 ~/tri/c\\n\\\n+ { bc }\\n~/tri/b 2:1 ~/tri/c\\n\\\n+ :beauty {more beautiful}\\n{ pretty }\\n~/tri/a 2:1 ~/tri/b\\n\"\n+ .to_string(),\n+ principal: \"testuser\".into(),\n+ delegate: None,\n+ room_id: \"public\".into(),\n+ thread_tag: \"tri\".into(),\n+ }));\n+ }\n+ let client = reqwest::Client::new();\n+ let resp = client\n+ .get(format!(\"http://{addr}/vote\"))\n+ .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(resp.status().is_success(), \"{}\", resp.status());\n+ let body = resp.text().await.unwrap();\n+ assert!(body.contains(\"judge one pair\"), \"landing intro missing\");\n+ assert!(body.contains(\":beauty\"), \"aspect question missing\");\n+ assert!(body.contains(\"1 of 3\"), \"aspect need count missing\");\n+ assert!(body.contains(\"name=\\\"aspect\\\"\"), \"aspect input missing\");\n+ assert!(body.contains(\"value=\\\"beauty\\\"\"), \"aspect value missing\");\n+ assert!(body.contains(\"value=\\\"tri\\\"\"), \"thread_tag should seed to pool leaf\");\n+}\n+\n #[tokio::test]\n async fn test_vote_aspect_query_param() {\n let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[237a0ec0] Home is a question index; forum index moves to /t; /q/ pages are self-contained.\n\nGET / lists public questions (scopes with 2+ comparable members, plus aspect\nrows) as expandable rows embedding the shared compare panel; votes morph in\nplace via VoteComparePost with per-row DOM suffixes. GET /t is the old forum\nhome (SSE prefixes follow). /q/:leaf pages gain aspects, compact standings,\nand collection members via shared question_body sections.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex 2b55bfb0cf2486186f98db7fd30c3708e73c2226..79b8633a72c0b6ca8f7361129fb471566179bf57 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -18,7 +18,8 @@ The web app is **not** a SPA with a JSON API for every interaction. Many mutatio\n \n - **End-to-end tests** that only assert HTTP status bodies miss DOM updates. Morph paths are covered by **Playwright / Spel** tests under `test/browser_*.clj` and `clojure -M -m test.runner …` (see `scripts/clj-test.sh`).\n - Shareable URLs are normal GET routes (e.g. `/t/:tag`, thread post views). **Expand/collapse** and similar controls are **actions**, not bookmarkable GET endpoints; they use the same `POST /ui` + `__rpc__` pattern where applicable.\n-- The home page (`GET /`) header shows public human/AI post counts from the reducer (`public_post_stats`: no `delegate` → human, `delegate` set → AI; `system:…` and redacted/private omitted). The `npx slugsocial` splash remains the static embedded `GUIDE.sorter` (no network).\n+- The home page (`GET /`) is a public **question index**: expandable `
` rows for each garden scope with ≥2 comparable members (canonical `/q/:leaf`) plus aspect prompts under those scopes (`/q/:leaf/:aspect`). Expanding a row reveals the same pair-compare form as the `/q/` page (`VoteComparePost`, seeded `thread_tag` / `aspect`). Order is bump-style (forum thread + vote activity), then member count descending, capped at 50. The header still shows public human/AI post counts from the reducer (`public_post_stats`: no `delegate` → human, `delegate` set → AI; `system:…` and redacted/private omitted). Links to **`/t`** (threads) and **`/~`** (garden). The `npx slugsocial` splash remains the static embedded `GUIDE.sorter` (no network).\n+- The public **thread index** is **`GET /t`** (former home forum list: private rooms when signed in, bump-ordered public threads, new-thread slot, `public_post_stats` header). Live feed SSE prefixes for the public bump list are `/t` and `/t/:tag` (not `/`). `/t/` 308s to `/t`. `/t/:tag` thread pages are unchanged. Brand / home links stay `/`; forum-index links point at `/t`.\n - Forum and garden item bodies use **`~/…` linkification** (`linkify_slugs_with_prefix` in `server/src/html/mod.rs`). Tilde refs emit **leaf hrefs and leaf display** (`~/x/luke` → `/~/luke` and `~/luke`). When **`item_bodies`** is in scope, matching ontology links get a native **`title`** tooltip with a **truncated body preview** (hover in the browser).\n \n - **Thread pagination and the SSE push path are page-scoped.** Thread pages (`/t/:tag?offset=N`) are **fixed windows aligned to `PAGE_SIZE` boundaries** (`server/src/html/forum/paginator.rs`): the latest page grows by appending until full, so existing posts never shift; arbitrary `?offset=` values snap to the containing page. Live pushes after a post/redact/graduate (`broadcast_web_refresh` in `server/src/api/write_actor.rs` → `thread_region_page_morphs` in `server/src/html/forum/feed.rs`) morph `#thread-feed-region` only behind **client-side page-offset guards** (`JsBuilder::if_page_offset_*`): the latest page, the page before it (its paginator gains the live `newer →` link at rollover), and — for redactions — the page containing the changed post. Viewers reading older pages are never overwritten with the latest posts. The poster's own `POST /ui` response (`post_success_response` in `server/src/api/ui_html.rs`) morphs in place only on the latest page and otherwise redirects to it.\n@@ -49,9 +50,9 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\n \n - **`CopyGardenRank`:** Browser copy control on garden ranking headings. Returns **`text/javascript`** via **`JsBuilder::clipboard_write_text_and_label_btn`** (same **`fetch` → `eval`** loop as **`CopyThread`**). Payload includes **`room`**, **`parent_path`**, **`depth`**, **`copy_btn_id`**, and optional **`external_hosts`** (for **`/-/`** host-root indexes). Clipboard text is a concise markdown numbered list of **leaf** display paths (plus unranked bullets).\n \n-- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. Optional **`aspect`** (same `$form` hole) prefixes the posted DSL with `:{slug}` so the vote lands in that aspect group; omitted / empty is the canonical ranking. **Guests** on a shared pair see the compose UI with **`post vote`** as a link to **`/login?next=`** (class **`vote-compare-login-cta`**); after OAuth / username selection they return to that matchup. An unauthenticated **`VoteComparePost`** (forged/stale form) still JS-redirects to the same **`/login?next=`** target.\n+- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
      `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). On `/q/` pages the same response also morphs **`#question-standings-region`**. The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. Optional **`aspect`** (same `$form` hole) prefixes the posted DSL with `:{slug}` so the vote lands in that aspect group; omitted / empty is the canonical ranking. **Guests** on a shared pair see the compose UI with **`post vote`** as a link to **`/login?next=`** (class **`vote-compare-login-cta`**); after OAuth / username selection they return to that matchup. An unauthenticated **`VoteComparePost`** (forged/stale form) still JS-redirects to the same **`/login?next=`** target.\n \n-- **Question pages (`GET /q/:collection` / `GET /q/:collection/:aspect`):** Shareable rendering of pairwise compare seeded by a garden scope (and optional aspect). Same chromeless compare machinery, `VoteComparePost`, and guest `next=` login (return to the `/q/` URL). Opening the page picks the next pair from the scope electorate. Votes seed `thread_tag` to the collection leaf; the narrowed URL carries `aspect` on the existing `__rpc__` / `$form` payload (no new POST). After a vote, next-pair stays on `/q/…` so the aspect is preserved. Subordinate links go to `/~/:collection` (plus `#aspect-:slug` when narrowed) and `/t/:collection`. Room-scoped `/r/:room_key/q/…`. Empty electorate: prompt + those links, no pair. Unknown collection (or invalid aspect slug): normal not-found. Aspect prompts remain room+slug scoped (known limitation).\n+- **Question pages (`GET /q/:collection` / `GET /q/:collection/:aspect`):** The shareable “invitation to judge” — chromeless full-bleed page: prompt headline, live compare pair (`VoteComparePost`, guest `next=` returns to the `/q/` URL), aspect list (from `aspect_groups` + `aspect_prompt`; aspect page links back to the canonical question and siblings), current standings (canonical or the aspect group; compact reuse of garden ranking), collection members (active + suspended/bordered), and a subordinate thread link to `/t/:collection`. Opening the page picks the next pair from the scope electorate. Votes seed `thread_tag` to the collection leaf; the narrowed URL carries `aspect` on the existing `__rpc__` / `$form` payload (no new POST). After a vote, next-pair stays on `/q/…` and the same JS morph refreshes `#vote-edge-history-region`, `.vote-compare-nav` / `#vote-compare-nav`, and `#question-standings-region`. Ranking link still goes to `/~/:collection` (plus `#aspect-:slug` when narrowed). Room-scoped `/r/:room_key/q/…`. Empty electorate: prompt + extras, no pair. Unknown collection (or invalid aspect slug): normal not-found. Aspect prompts remain room+slug scoped (known limitation).\n \n - **`ThreadGraduate` / `GraduateThread`:** Private-room forum threads with **Manage** can be published to the public site under the same tag. The writer replays non-redacted ingests into **`room: public`** (chronological order), then appends a durable **`ThreadGraduated`** marker. Graduated private threads show a banner linking to public **`/t/:tag`**, block further private posts, and cannot be graduated twice. CLI: **`npx slugsocial private forum graduate `**; RPC: **`ThreadGraduate`**.\n \ndiff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter\nindex 66b1ffa4e4fd4d3612b1db90481621f422f717bf..48bb47e1a9b9aed77849b3b138e162c684b2c9fb 100644\n--- a/cli/GUIDE.sorter\n+++ b/cli/GUIDE.sorter\n@@ -12,7 +12,7 @@ The garden is the ontology - a collectively built structure of items addressed b\n \n Items accumulate votes over time. Rankings emerge. The garden grows.\n \n-The forum is the threads - topic-based sessions like 4chan or teamfortress.tv. Threads are how people find what's in circulation right now. Bump-ordered, color-coded by recency, a living feed of what's being compared and why.\n+The forum is the threads - topic-based sessions like 4chan or teamfortress.tv. Threads are how people find what's in circulation right now. Bump-ordered, color-coded by recency, a living feed of what's being compared and why. The public thread index lives at https://slug.social/t ; the site home (https://slug.social) is an index of pairwise questions over garden scopes.\n \n These are different things. A thread is a session. The ontology is permanent. Multiple threads can touch the same items from different angles. The thread passes. The item and its votes remain.\n \n@@ -64,7 +64,7 @@ Two kinds of things. In .sorter documents you write #thread and ~name (or ~/path\n \n #thread - a topic-based session (the forum layer)\n bump-ordered, like 4chan or teamfortress.tv\n- entry point / feed\n+ listed at /t (the site root / is the question index)\n \n #thread: subtitle - thread with a 100-char max subtitle\n Set on first post, immutable thereafter. Provides a readable headline for the thread.\n@@ -235,8 +235,9 @@ Add --json to scoped commands for machine-readable output (RPC-shaped JSON where\n }\n \n ~/contact {\n-Web: https://slug.social\n-Questions: https://slug.social/q/psalms/beauty (shareable pairwise prompt over a garden scope)\n+Web: https://slug.social — question index (scopes you can judge)\n+Threads: https://slug.social/t\n+Questions: https://slug.social/q/psalms (a collection) or /q/psalms/beauty (an aspect)\n GitHub: https://github.com/sortersocial/slug\n Issues/feedback: https://github.com/sortersocial/slug/issues\n }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 30b1b2613ff35e4b45673f76f36709f5936853ef..02597c22d4283ea7df83f35cd8d71060a9d9c7ee 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -212,6 +212,7 @@ async fn dispatch_ui_action(\n next,\n pool,\n aspect,\n+ dom_suffix,\n form_action,\n } => {\n if form_action != \"/ui\" {\n@@ -224,7 +225,10 @@ async fn dispatch_ui_action(\n let Some(session) = session else {\n return js_redirect(&login_redirect_for(&next)).into_response();\n };\n- let err_tgt = Some(\"vote-compare-errors\".to_string());\n+ let err_tgt = Some(match dom_suffix.as_deref().filter(|s| !s.is_empty()) {\n+ Some(s) => format!(\"vote-compare-errors-{s}\"),\n+ None => \"vote-compare-errors\".to_string(),\n+ });\n let room = room.trim().to_string();\n let thread_tag = match validate_thread_tag(&thread_tag) {\n Ok(t) => t,\n@@ -359,6 +363,7 @@ async fn dispatch_ui_action(\n pid.as_str(),\n post_index,\n &next,\n+ dom_suffix.as_deref(),\n )\n .await;\n Response::builder()\ndiff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs\nindex e4177f1984c2ccbce569509fa78810e1d51eadce..76531a7b42015fec5c26ac1bae3dff47f50b75e1 100644\n--- a/server/src/api/write_actor.rs\n+++ b/server/src/api/write_actor.rs\n@@ -96,7 +96,7 @@ async fn broadcast_web_refresh(\n .await;\n \n // Two SSE payloads: the bump-list morph must not ship private HTML to subscribers who only\n- // matched `/` (public) or lack room access — see [`crate::api::stream::get_html_stream`].\n+ // matched `/t` (public thread index) or lack room access — see [`crate::api::stream::get_html_stream`].\n let feed_builder = JsBuilder::new().morph_selector(&format!(\"#{feed_id}\"), feed_markup);\n let feed_builder = feed_builder.qs(\"#new-thread-compose form\").reset();\n let feed_js = feed_builder.build();\n@@ -122,11 +122,11 @@ async fn broadcast_web_refresh(\n };\n \n let feed_prefixes = if room_key == \"public\" {\n- vec![\"/\".to_string(), thread_url.clone()]\n+ vec![\"/t\".to_string(), thread_url.clone()]\n } else if let Some(seg) = room_route_segment(room_key) {\n vec![format!(\"/r/{seg}\"), thread_url.clone()]\n } else {\n- vec![\"/\".to_string(), thread_url.clone()]\n+ vec![\"/t\".to_string(), thread_url.clone()]\n };\n \n let _ = state.js_tx.send(crate::state::JsSnippet {\ndiff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs\nindex a0abb8b706f606f2bd4053f386fc6ce072adb07b..b51cd7dc1f1f2791f5138bc3040e4e9bad602d0f 100644\n--- a/server/src/html/breadcrumb_path.rs\n+++ b/server/src/html/breadcrumb_path.rs\n@@ -56,7 +56,7 @@ impl OntologyPath {\n self.segments.is_empty()\n }\n \n- /// At ontology root, allow mode toggle to forum (`/`).\n+ /// At ontology root, allow mode toggle to home (`/`).\n /// In deeper ontology views, keep root breadcrumb within garden (`/~`).\n pub(super) fn slug_root_href(&self) -> &'static str {\n if self.is_root() {\ndiff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs\nindex a96dc1f009cebc5160a4e085785f84cc446dd340..5b4f5a167f7cff337e30751f168c69ef1d61e535 100644\n--- a/server/src/html/forum/feed.rs\n+++ b/server/src/html/forum/feed.rs\n@@ -304,8 +304,8 @@ pub(crate) async fn thread_latest_page_region(\n (latest_offset, markup)\n }\n \n-/// Home: private rooms (signed-in), then public bump-ordered threads.\n-pub async fn home(\n+/// Public thread index (`GET /t`): private rooms (signed-in), then public bump-ordered threads.\n+pub async fn thread_index(\n State(state): State,\n headers: HeaderMap,\n jar: CookieJar,\ndiff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs\nindex 3c39a4c7d4ee9172998aa9d8697642e75899c806..3b1d0ef9c32da42d0e1773d98f31cf233086f700 100644\n--- a/server/src/html/forum/mod.rs\n+++ b/server/src/html/forum/mod.rs\n@@ -1,4 +1,4 @@\n-//! Forum / thread HTML: public home, room index, thread views, profile, and UI morph helpers.\n+//! Forum / thread HTML: public thread index (`/t`), room index, thread views, profile, and UI morph helpers.\n \n mod access;\n mod copy;\n@@ -15,11 +15,12 @@ mod room_members;\n mod thread_morph;\n mod views;\n \n-pub use feed::{home, thread_feed_html, thread_feed_html_for_room};\n+pub use feed::{thread_feed_html, thread_feed_html_for_room, thread_index};\n pub(crate) use feed::{\n thread_latest_page_region, thread_region_page_morphs, ThreadRegionPageMorphs,\n };\n pub use nav::ThreadNav;\n+pub(crate) use page::auth_strip;\n pub(crate) use paginator::PAGE_SIZE as THREAD_PAGE_SIZE;\n pub use post_single::{room_thread_post_view, thread_post_view};\n pub use profile::user_profile_page;\ndiff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs\nindex a93341119ed05885e06f365e5f4def740184df31..b3f06738918ffe6dd343e7579641e7cb6cb4adf8 100644\n--- a/server/src/html/forum/new_thread.rs\n+++ b/server/src/html/forum/new_thread.rs\n@@ -5,7 +5,7 @@ use serde_json::json;\n \n use super::nav::ThreadNav;\n \n-/// Stable ids shared by public home and private room “new thread” UI (`#new-thread-ui-slot`).\n+/// Stable ids shared by the public thread index (`/t`) and private room “new thread” UI (`#new-thread-ui-slot`).\n const COMPOSE_SECTION_ID: &str = \"new-thread-compose\";\n const ERRORS_ID: &str = \"new-thread-errors\";\n const FORM_ID: &str = \"new-thread-form\";\ndiff --git a/server/src/html/forum/page.rs b/server/src/html/forum/page.rs\nindex 2697f0a3826b543acec842ed08f2d0f88a2470eb..933311adb9e3856f2a016c77fefe6a33ae8f5692 100644\n--- a/server/src/html/forum/page.rs\n+++ b/server/src/html/forum/page.rs\n@@ -8,7 +8,7 @@ use crate::reducer::ReducerState;\n use super::nav::ThreadNav;\n use crate::html::bc_segment;\n \n-pub(super) fn auth_strip(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Markup {\n+pub(crate) fn auth_strip(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Markup {\n match optional_principal(headers, jar, reduced) {\n Some(u) => html! {\n p class=\"muted auth-strip\" {\ndiff --git a/server/src/html/garden/home.rs b/server/src/html/garden/home.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..be464aa0ed27f7811d5899d1880b4579ee04ab67\n--- /dev/null\n+++ b/server/src/html/garden/home.rs\n@@ -0,0 +1,293 @@\n+//! Public home: index of shareable `/q/` questions over garden scopes.\n+\n+use axum::{\n+ extract::State,\n+ http::{HeaderMap, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::html;\n+\n+use crate::{\n+ api::optional_principal,\n+ canonical_path::canonicalize_tag,\n+ dsl::is_valid_aspect_slug,\n+ html::{\n+ forum::{auth_strip, ThreadNav},\n+ layout_with_post_stats, theme_from_jar, theme_next_from_uri,\n+ },\n+ middleware::canonical_view_url,\n+ path_types::ItemId,\n+ reducer::{ContentState, ReducerState, ScopeId},\n+ scope_rank::comparable_items,\n+ state::AppState,\n+};\n+\n+use super::{\n+ access::content_for_garden_view,\n+ question::{\n+ build_question_ctx, parse_collection_leaf, question_headline, question_title,\n+ },\n+ question_body::aspects_for_scope,\n+ vote::{question_vote_panel, QuestionHeadline, VoteCompareDomIds},\n+};\n+\n+const QUESTION_INDEX_CAP: usize = 50;\n+\n+pub(super) struct QuestionIndexEntry {\n+ pub collection: ItemId,\n+ pub aspect: Option,\n+ pub leaf: String,\n+ pub title: String,\n+ pub member_count: usize,\n+ pub question_path: String,\n+ pub garden_href: String,\n+ pub thread_href: String,\n+ pub headline: QuestionHeadline,\n+ pub dom_suffix: String,\n+}\n+\n+fn is_listable_scope(id: &ItemId) -> bool {\n+ matches!(id.tilde_tail(), Some(t) if !t.is_empty() && !t.contains('/'))\n+}\n+\n+fn question_activity(\n+ reduced: &ReducerState,\n+ content: &ContentState,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+) -> i64 {\n+ let leaf = canonicalize_tag(&collection.last_segment());\n+ let thread_ts = reduced\n+ .forum_threads\n+ .get(&(ScopeId::Public, leaf))\n+ .map(|t| t.last_activity_ts)\n+ .unwrap_or(0);\n+ let vote_ts = if let Some(slug) = aspect {\n+ content\n+ .aspect_group(collection, slug)\n+ .and_then(|g| g.recent_votes.front().map(|v| v.ts))\n+ .unwrap_or(0)\n+ } else {\n+ let members: std::collections::HashSet =\n+ content.members_of(collection).into_iter().collect();\n+ content\n+ .ranking_group\n+ .recent_votes\n+ .iter()\n+ .filter(|v| members.contains(&v.a) && members.contains(&v.b))\n+ .map(|v| v.ts)\n+ .max()\n+ .unwrap_or(0)\n+ };\n+ thread_ts.max(vote_ts)\n+}\n+\n+fn prompted_aspects(content: &ContentState, collection: &ItemId) -> Vec {\n+ let mut slugs: Vec = content\n+ .aspect_prompts\n+ .iter()\n+ .filter(|(_, p)| !p.trim().is_empty())\n+ .map(|(slug, _)| slug.clone())\n+ .filter(|slug| is_valid_aspect_slug(slug))\n+ .collect();\n+ for (slug, prompt) in aspects_for_scope(content, collection) {\n+ if prompt.as_deref().is_some_and(|p| !p.is_empty()) && is_valid_aspect_slug(&slug) {\n+ slugs.push(slug);\n+ }\n+ }\n+ slugs.sort();\n+ slugs.dedup();\n+ slugs\n+}\n+\n+fn entry_from_scope(\n+ content: &ContentState,\n+ nav: &ThreadNav,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+ member_count: usize,\n+) -> Option {\n+ let leaf = collection.last_segment().to_string();\n+ if parse_collection_leaf(&leaf).as_ref() != Some(collection) {\n+ return None;\n+ }\n+ if let Some(slug) = aspect {\n+ if !is_valid_aspect_slug(slug) {\n+ return None;\n+ }\n+ }\n+ let headline = question_headline(content, collection, aspect);\n+ let title = question_title(&headline);\n+ let ctx = build_question_ctx(nav, collection, aspect, headline.clone());\n+ let dom_suffix = match aspect {\n+ Some(a) => format!(\"{leaf}-{a}\"),\n+ None => leaf.clone(),\n+ };\n+ Some(QuestionIndexEntry {\n+ collection: collection.clone(),\n+ aspect: aspect.map(str::to_string),\n+ leaf,\n+ title,\n+ member_count,\n+ question_path: ctx.question_path,\n+ garden_href: ctx.garden_href,\n+ thread_href: ctx.thread_href,\n+ headline,\n+ dom_suffix,\n+ })\n+}\n+\n+/// Public scopes with ≥2 comparable members, plus prompted aspects under those scopes.\n+/// Bump-ordered by thread / vote activity, then member count descending.\n+pub(super) fn collect_question_entries(\n+ reduced: &ReducerState,\n+ content: &ContentState,\n+ nav: &ThreadNav,\n+) -> Vec {\n+ let mut scopes: Vec<(ItemId, usize)> = content\n+ .members_by_scope\n+ .keys()\n+ .filter(|id| is_listable_scope(id))\n+ .filter_map(|id| {\n+ let n = comparable_items(content, content.members_of(id)).len();\n+ (n >= 2).then_some((id.clone(), n))\n+ })\n+ .collect();\n+ scopes.sort_by(|a, b| {\n+ let act_a = question_activity(reduced, content, &a.0, None);\n+ let act_b = question_activity(reduced, content, &b.0, None);\n+ act_b\n+ .cmp(&act_a)\n+ .then(b.1.cmp(&a.1))\n+ .then(a.0.as_str().cmp(b.0.as_str()))\n+ });\n+\n+ let mut entries = Vec::new();\n+ for (scope, member_count) in scopes {\n+ if let Some(e) = entry_from_scope(content, nav, &scope, None, member_count) {\n+ entries.push(e);\n+ }\n+ for slug in prompted_aspects(content, &scope) {\n+ if let Some(e) = entry_from_scope(content, nav, &scope, Some(&slug), member_count) {\n+ entries.push(e);\n+ }\n+ }\n+ if entries.len() >= QUESTION_INDEX_CAP {\n+ break;\n+ }\n+ }\n+ entries.truncate(QUESTION_INDEX_CAP);\n+ entries\n+}\n+\n+fn question_row_markup(entry: &QuestionIndexEntry, panel: maud::Markup) -> maud::Markup {\n+ let row_id = format!(\"question-row-{}\", entry.dom_suffix);\n+ html! {\n+ details class=\"question-row\" id=(row_id) data-testid=\"question-row\"\n+ data-question-leaf=(entry.leaf.as_str())\n+ data-question-aspect=(entry.aspect.as_deref().unwrap_or(\"\")) {\n+ summary class=\"question-row-summary\" {\n+ span class=\"question-row-title\" { (entry.title) }\n+ @if let Some(aspect) = &entry.aspect {\n+ span class=\"muted question-row-aspect\" { \" · :\" (aspect) }\n+ }\n+ span class=\"muted question-row-count\" { \" · \" (entry.member_count) }\n+ }\n+ p class=\"muted question-row-links\" {\n+ a href=(entry.question_path) { \"full question\" }\n+ \" · \"\n+ a href=(entry.garden_href) { \"garden\" }\n+ \" · \"\n+ a href=(entry.thread_href) { \"thread\" }\n+ }\n+ (panel)\n+ }\n+ }\n+}\n+\n+/// `GET /` — public question index.\n+pub async fn home(\n+ State(state): State,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let nav = ThreadNav::public();\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ let logged_in = user.is_some();\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let entries = collect_question_entries(&reduced, content, &nav);\n+ let post_stats = reduced.public_post_stats();\n+ let strip = auth_strip(&headers, &jar, &reduced);\n+\n+ let mut rows = Vec::with_capacity(entries.len());\n+ for entry in &entries {\n+ let ctx = build_question_ctx(\n+ &nav,\n+ &entry.collection,\n+ entry.aspect.as_deref(),\n+ entry.headline.clone(),\n+ );\n+ let ids = VoteCompareDomIds::with_suffix(&entry.dom_suffix);\n+ let panel = question_vote_panel(\n+ content,\n+ &nav,\n+ &entry.collection,\n+ entry.aspect.as_deref(),\n+ logged_in,\n+ logged_in,\n+ &entry.question_path,\n+ Some(entry.question_path.as_str()),\n+ Some(&ctx),\n+ false,\n+ &ids,\n+ );\n+ rows.push(question_row_markup(entry, panel));\n+ }\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_with_post_stats(\n+ \"slug.social\",\n+ \"view-thread view-question-index view-ontology\",\n+ html! {\n+ (strip)\n+ nav class=\"breadcrumb\" {\n+ a href=\"/\" class=\"bc-current\" { \"slug.social\" }\n+ }\n+ header class=\"home-intro\" {\n+ h1 { \"slug.social\" }\n+ p class=\"home-lede\" {\n+ \"pairwise questions over the public garden\"\n+ }\n+ p class=\"muted home-nav-links\" {\n+ a href=\"/t\" { \"threads\" }\n+ \" · \"\n+ a href=\"/~\" { \"garden\" }\n+ }\n+ }\n+ @if rows.is_empty() {\n+ p class=\"muted\" data-testid=\"question-index-empty\" {\n+ \"no questions yet — a garden scope needs two items with bodies.\"\n+ }\n+ } @else {\n+ div class=\"question-index\" data-testid=\"question-index\" {\n+ @for row in rows {\n+ (row)\n+ }\n+ }\n+ }\n+ },\n+ Some(view_count),\n+ post_stats,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ None,\n+ None,\n+ );\n+ Html(page.into_string())\n+}\ndiff --git a/server/src/html/garden/mod.rs b/server/src/html/garden/mod.rs\nindex bda048dae032bfd9d59cfd68a6b098ef684527a6..097f200af8034781accf6024ca5beabcd8d6d7d8 100644\n--- a/server/src/html/garden/mod.rs\n+++ b/server/src/html/garden/mod.rs\n@@ -4,10 +4,12 @@ mod access;\n mod browse;\n mod copy;\n mod external;\n+mod home;\n mod item;\n mod item_page;\n mod pin;\n mod question;\n+mod question_body;\n mod render;\n mod routes;\n mod vote;\n@@ -20,6 +22,7 @@ pub(crate) use external::external_resolver_status_markup;\n pub(crate) use pin::{encode_pin_cookie_value, GARDEN_PIN_COOKIE};\n pub(crate) use vote::vote_compare_post_success_js;\n \n+pub use home::home;\n pub use question::{\n question_aspect_page, question_page, room_question_aspect_page, room_question_page,\n };\ndiff --git a/server/src/html/garden/question.rs b/server/src/html/garden/question.rs\nindex 9c1607eec45354d05ff0d251a0a7a3f5e523522c..5dccc4b6c4b0eac52240677ad6239a646f57a8fd 100644\n--- a/server/src/html/garden/question.rs\n+++ b/server/src/html/garden/question.rs\n@@ -16,11 +16,11 @@ use crate::{\n dsl::is_valid_aspect_slug,\n html::{\n forum::ThreadNav, layout_full_bleed_chromeless, render_item_body_in_scope, theme_from_jar,\n- theme_next_from_uri,\n+ theme_next_from_uri, user_can_post_room,\n },\n middleware::canonical_view_url,\n path_types::ItemId,\n- reducer::ContentState,\n+ reducer::{ContentState, ScopeId},\n scope_rank::comparable_items,\n state::AppState,\n };\n@@ -30,7 +30,8 @@ use super::{\n content_for_garden_view, room_not_found_page, room_scope_has_garden_content,\n user_can_view_room,\n },\n- vote::{vote_compare_inner, QuestionCtx, QuestionHeadline, VoteCompareQuery},\n+ question_body::question_context_sections,\n+ vote::{question_vote_panel, QuestionCtx, QuestionHeadline, VoteCompareDomIds},\n };\n \n /// `psalms` → leaf item `~psalms`. Rejects root and multi-segment tails.\n@@ -83,7 +84,7 @@ pub(super) fn question_headline(\n QuestionHeadline::Fallback(format!(\"Which is greater: {}?\", collection.last_segment()))\n }\n \n-fn question_title(headline: &QuestionHeadline) -> String {\n+pub(super) fn question_title(headline: &QuestionHeadline) -> String {\n match headline {\n QuestionHeadline::Fallback(s) => s.clone(),\n QuestionHeadline::Body(b) => {\n@@ -107,7 +108,7 @@ fn ranking_href(nav: &ThreadNav, collection: &ItemId, aspect: Option<&str>) -> S\n }\n }\n \n-fn build_question_ctx(\n+pub(super) fn build_question_ctx(\n nav: &ThreadNav,\n collection: &ItemId,\n aspect: Option<&str>,\n@@ -124,41 +125,43 @@ fn build_question_ctx(\n }\n }\n \n-fn question_empty_page(\n- state: &AppState,\n- nav: &ThreadNav,\n- uri: &Uri,\n- jar: &CookieJar,\n- ctx: QuestionCtx,\n-) -> axum::response::Response {\n- let url_key = canonical_view_url(uri);\n- let view_count = state.views.get_views(&url_key);\n- let body = html! {\n- section class=\"vote-compare-shell\" {\n- header class=\"vote-question\" {\n- @match &ctx.headline {\n- QuestionHeadline::Body(text) => {\n- div class=\"vote-question-headline\" {\n- (render_item_body_in_scope(text, nav.garden_root_url(), None))\n- }\n- }\n- QuestionHeadline::Fallback(text) => {\n- h1 class=\"vote-question-headline\" { (text) }\n+fn question_header_markup(\n+ ctx: &QuestionCtx,\n+ garden_root: &str,\n+ item_bodies: Option<&std::collections::HashMap>,\n+) -> maud::Markup {\n+ html! {\n+ header class=\"vote-question\" {\n+ @match &ctx.headline {\n+ QuestionHeadline::Body(text) => {\n+ div class=\"vote-question-headline\" {\n+ (render_item_body_in_scope(text, garden_root, item_bodies))\n }\n }\n- nav class=\"vote-question-links muted\" {\n- a data-testid=\"question-ranking-link\" href=(ctx.garden_href) { \"ranking\" }\n- \" · \"\n- a data-testid=\"question-thread-link\" href=(ctx.thread_href) { \"thread\" }\n+ QuestionHeadline::Fallback(text) => {\n+ h1 class=\"vote-question-headline\" { (text) }\n }\n }\n- p class=\"muted vote-question-empty\" {\n- \"nothing to compare yet — this scope needs at least two items with bodies.\"\n+ nav class=\"vote-question-links muted\" {\n+ a data-testid=\"question-ranking-link\" href=(ctx.garden_href) { \"ranking\" }\n+ \" · \"\n+ a data-testid=\"question-thread-link\" href=(ctx.thread_href) { \"thread\" }\n }\n }\n- };\n+ }\n+}\n+\n+fn question_page_shell(\n+ state: &AppState,\n+ uri: &Uri,\n+ jar: &CookieJar,\n+ title: &str,\n+ body: maud::Markup,\n+) -> axum::response::Response {\n+ let url_key = canonical_view_url(uri);\n+ let view_count = state.views.get_views(&url_key);\n let page = layout_full_bleed_chromeless(\n- &ctx.title,\n+ title,\n \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen view-vote-question\",\n body,\n Some(view_count),\n@@ -193,23 +196,56 @@ async fn question_inner(\n return room_not_found_page(&jar, &uri).into_response();\n }\n let headline = question_headline(content, &collection_id, aspect.as_deref());\n- let members = comparable_items(content, content.members_of(&collection_id));\n let ctx = build_question_ctx(&nav, &collection_id, aspect.as_deref(), headline);\n- let leaf = collection_id.last_segment().to_string();\n- let pool_display = collection_id.display_path();\n+ let extras = question_context_sections(content, &nav, &collection_id, aspect.as_deref());\n+ let members = comparable_items(content, content.members_of(&collection_id));\n+ let viewer = optional_principal(&headers, &jar, &reduced);\n+ let logged_in = viewer.is_some();\n+ let can_post = match &nav.scope() {\n+ ScopeId::Public => logged_in,\n+ ScopeId::Room(rid) => viewer\n+ .as_ref()\n+ .map(|u| user_can_post_room(&reduced, rid, u))\n+ .unwrap_or(false),\n+ };\n+ let next_path = uri\n+ .path_and_query()\n+ .map(|pq| pq.as_str().to_string())\n+ .unwrap_or_else(|| ctx.question_path.clone());\n+ let pair = if members.len() < 2 {\n+ let item_bodies = content.item_bodies.clone();\n+ html! {\n+ section class=\"vote-compare-shell\" {\n+ (question_header_markup(&ctx, nav.garden_root_url(), Some(&item_bodies)))\n+ p class=\"muted vote-question-empty\" {\n+ \"nothing to compare yet — this scope needs at least two items with bodies.\"\n+ }\n+ }\n+ }\n+ } else {\n+ question_vote_panel(\n+ content,\n+ &nav,\n+ &collection_id,\n+ aspect.as_deref(),\n+ logged_in,\n+ can_post,\n+ &next_path,\n+ Some(ctx.question_path.as_str()),\n+ Some(&ctx),\n+ true,\n+ &VoteCompareDomIds::page(),\n+ )\n+ };\n drop(reduced);\n \n- if members.len() < 2 {\n- return question_empty_page(&state, &nav, &uri, &jar, ctx);\n- }\n-\n- let q = VoteCompareQuery {\n- left: None,\n- right: None,\n- thread: Some(leaf),\n- pool: Some(pool_display),\n+ let body = html! {\n+ div class=\"vote-question-page\" {\n+ (pair)\n+ (extras)\n+ }\n };\n- vote_compare_inner(state, q, nav, headers, jar, uri, Some(ctx)).await\n+ question_page_shell(&state, &uri, &jar, &ctx.title, body)\n }\n \n /// `GET /q/:collection`\ndiff --git a/server/src/html/garden/question_body.rs b/server/src/html/garden/question_body.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e1fa4e86406656eb550f0851532f27e2ed07c0cf\n--- /dev/null\n+++ b/server/src/html/garden/question_body.rs\n@@ -0,0 +1,298 @@\n+//! Shared question-page sections: aspects, compact standings, collection members.\n+//! Used by `GET /q/…` and the public question index (`GET /`).\n+\n+use std::collections::HashSet;\n+\n+use maud::html;\n+\n+use crate::{\n+ html::forum::ThreadNav,\n+ path_types::ItemId,\n+ reducer::{BorderPairState, ContentState, GroupState, MembershipStatus},\n+ scope_rank::{build_children_rankings, build_children_rankings_in_group, ChildrenRankings},\n+};\n+\n+use super::item::item_display_path;\n+\n+pub(super) fn aspects_for_scope(\n+ content: &ContentState,\n+ collection: &ItemId,\n+) -> Vec<(String, Option)> {\n+ let parent = collection.clone().normalized_storage();\n+ let mut slugs: Vec = content\n+ .aspect_groups\n+ .keys()\n+ .filter(|(p, _)| p == &parent)\n+ .map(|(_, slug)| slug.clone())\n+ .collect();\n+ slugs.sort();\n+ slugs.dedup();\n+ slugs\n+ .into_iter()\n+ .map(|slug| {\n+ let prompt = content\n+ .aspect_prompt(&slug)\n+ .map(str::trim)\n+ .filter(|s| !s.is_empty())\n+ .map(str::to_string);\n+ (slug, prompt)\n+ })\n+ .collect()\n+}\n+\n+pub(super) fn question_rankings(\n+ content: &ContentState,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+) -> ChildrenRankings {\n+ if let Some(slug) = aspect.filter(|s| !s.is_empty()) {\n+ if let Some(group) = content.aspect_group(collection, slug) {\n+ return build_children_rankings_in_group(content, collection, group);\n+ }\n+ }\n+ build_children_rankings(content, collection)\n+}\n+\n+#[allow(dead_code)]\n+pub(super) fn scoped_comparison_count(group: &GroupState, members: &[ItemId]) -> usize {\n+ let set: HashSet<&ItemId> = members.iter().collect();\n+ group\n+ .voted_pairs\n+ .iter()\n+ .filter(|(i, j)| {\n+ group.idx_to_item.get(*i).is_some_and(|a| set.contains(a))\n+ && group.idx_to_item.get(*j).is_some_and(|b| set.contains(b))\n+ })\n+ .count()\n+}\n+\n+#[allow(dead_code)]\n+pub(super) fn question_comparison_count(\n+ content: &ContentState,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+) -> usize {\n+ let members = content.members_of(collection);\n+ if let Some(slug) = aspect.filter(|s| !s.is_empty()) {\n+ if let Some(group) = content.aspect_group(collection, slug) {\n+ return scoped_comparison_count(group, &members);\n+ }\n+ return 0;\n+ }\n+ scoped_comparison_count(&content.ranking_group, &members)\n+}\n+\n+#[allow(dead_code)]\n+pub(super) fn question_vote_count(\n+ content: &ContentState,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+) -> usize {\n+ let members = content.members_of(collection);\n+ let set: HashSet = members.iter().cloned().collect();\n+ if let Some(slug) = aspect.filter(|s| !s.is_empty()) {\n+ return content\n+ .aspect_group(collection, slug)\n+ .map(|g| {\n+ g.recent_votes\n+ .iter()\n+ .filter(|v| set.contains(&v.a) && set.contains(&v.b))\n+ .count()\n+ })\n+ .unwrap_or(0);\n+ }\n+ let mut n = 0usize;\n+ for m in &members {\n+ if let Some(votes) = content.item_votes.get(m) {\n+ n += votes\n+ .iter()\n+ .filter(|v| set.contains(&v.a) && set.contains(&v.b))\n+ .count();\n+ }\n+ }\n+ n / 2\n+}\n+\n+fn suspended_in_scope(content: &ContentState, parent: &ItemId) -> Vec {\n+ let parent = parent.ontology_leaf();\n+ let mut seen = HashSet::new();\n+ let mut out = Vec::new();\n+ for (c, p) in content.containment.keys().chain(content.borders.keys()) {\n+ if p != &parent || !seen.insert(c.clone()) {\n+ continue;\n+ }\n+ if let Some(st) = content.border_state(c, p) {\n+ if st.status == MembershipStatus::Suspended {\n+ out.push(st);\n+ }\n+ }\n+ }\n+ out.sort_by(|a, b| a.child.as_str().cmp(b.child.as_str()));\n+ out\n+}\n+\n+/// Compact ranking: position, leaf href, score. `limit` truncates ranked rows (home preview).\n+pub(super) fn compact_standings_markup(\n+ rankings: &ChildrenRankings,\n+ nav: &ThreadNav,\n+ limit: Option,\n+) -> maud::Markup {\n+ let mut rows: Vec<(usize, ItemId, f64, usize)> = Vec::new();\n+ for comp in &rankings.component_rankings {\n+ for r in &comp.ranked {\n+ let pos = rows.len() + 1;\n+ rows.push((pos, r.item.clone(), r.score, comp.pairs));\n+ if limit.is_some_and(|n| rows.len() >= n) {\n+ break;\n+ }\n+ }\n+ if limit.is_some_and(|n| rows.len() >= n) {\n+ break;\n+ }\n+ }\n+ html! {\n+ @if rows.is_empty() && rankings.unranked_items.is_empty() {\n+ p class=\"muted\" { \"no standings yet\" }\n+ } @else {\n+ @if !rows.is_empty() {\n+ ol class=\"question-standings-list ont-ranking-list\" {\n+ @for (pos, item, score, pairs) in &rows {\n+ li data-garden-item=(item.as_str()) {\n+ span class=\"question-standings-rank\" { (pos) }\n+ \" \"\n+ a class=\"item-link\" href=(nav.garden_item_href(item)) {\n+ code { (item_display_path(item.as_str())) }\n+ }\n+ span class=\"ont-rank-score muted\" {\n+ (format!(\"{:.3}\", score))\n+ \" · \"\n+ (format!(\"{pairs}p\"))\n+ }\n+ }\n+ }\n+ }\n+ }\n+ @if limit.is_none() && !rankings.unranked_items.is_empty() {\n+ ul class=\"question-standings-unranked ont-group-list\" {\n+ @for item in &rankings.unranked_items {\n+ li data-garden-item=(item.as_str()) {\n+ a class=\"item-link\" href=(nav.garden_item_href(item)) {\n+ code { (item_display_path(item.as_str())) }\n+ }\n+ span class=\"muted\" { \" · unranked\" }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+pub(super) fn question_standings_region(\n+ rankings: &ChildrenRankings,\n+ nav: &ThreadNav,\n+) -> maud::Markup {\n+ html! {\n+ section id=\"question-standings-region\" class=\"question-standings\" data-testid=\"question-standings\" {\n+ h2 { \"standings\" }\n+ (compact_standings_markup(rankings, nav, None))\n+ }\n+ }\n+}\n+\n+pub(super) fn question_aspects_markup(\n+ nav: &ThreadNav,\n+ collection: &ItemId,\n+ current: Option<&str>,\n+ aspects: &[(String, Option)],\n+) -> maud::Markup {\n+ if aspects.is_empty() && current.is_none() {\n+ return html! {};\n+ }\n+ let leaf = collection.last_segment();\n+ html! {\n+ section class=\"question-aspects\" data-testid=\"question-aspects\" {\n+ h2 { \"aspects\" }\n+ @if current.is_some() {\n+ p class=\"question-aspects-canonical\" {\n+ a data-testid=\"question-canonical-link\" href=(nav.question_href(&leaf, None)) {\n+ \"canonical question\"\n+ }\n+ }\n+ }\n+ ul class=\"question-aspects-list\" {\n+ @for (slug, prompt) in aspects {\n+ li {\n+ @if current == Some(slug.as_str()) {\n+ span class=\"question-aspect-current\" { \":\" (slug) }\n+ } @else {\n+ a href=(nav.question_href(&leaf, Some(slug))) { \":\" (slug) }\n+ }\n+ @if let Some(p) = prompt {\n+ span class=\"muted\" { \" — \" (p) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+pub(super) fn question_members_markup(\n+ content: &ContentState,\n+ collection: &ItemId,\n+ nav: &ThreadNav,\n+) -> maud::Markup {\n+ let active = content.members_of(collection);\n+ let suspended = suspended_in_scope(content, collection);\n+ html! {\n+ section class=\"question-members\" data-testid=\"question-members\" {\n+ h2 { \"members\" }\n+ @if active.is_empty() && suspended.is_empty() {\n+ p class=\"muted\" { \"no members yet\" }\n+ } @else {\n+ ul class=\"question-members-list ont-group-list\" {\n+ @for m in &active {\n+ li {\n+ a href=(nav.garden_item_href(m)) {\n+ (item_display_path(m.as_str()))\n+ }\n+ }\n+ }\n+ @for st in &suspended {\n+ li class=\"muted ont-border-suspended\" {\n+ a href=(nav.garden_item_href(&st.child)) {\n+ (item_display_path(st.child.as_str()))\n+ }\n+ \" \"\n+ span { \"suspended\" }\n+ \" \"\n+ span {\n+ (format!(\n+ \"containment {} · border {}\",\n+ st.containment_weight, st.border_weight\n+ ))\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+/// Aspects + standings + members (thread link stays in the question header).\n+pub(super) fn question_context_sections(\n+ content: &ContentState,\n+ nav: &ThreadNav,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+) -> maud::Markup {\n+ let aspects = aspects_for_scope(content, collection);\n+ let rankings = question_rankings(content, collection, aspect);\n+ html! {\n+ (question_aspects_markup(nav, collection, aspect, &aspects))\n+ (question_standings_region(&rankings, nav))\n+ (question_members_markup(content, collection, nav))\n+ }\n+}\ndiff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs\nindex a4cc7ca2bd3db25ee51897e3b7cd329fee0556ee..29037d9287d326244f69741299fd46e8c8aff10c 100644\n--- a/server/src/html/garden/tests.rs\n+++ b/server/src/html/garden/tests.rs\n@@ -2,6 +2,7 @@ use super::{\n access::content_for_garden_view,\n browse::scoped_bc_containment,\n external::{external_frame_allowed, external_resolver_status_markup, external_source_href},\n+ home::collect_question_entries,\n item_page::{\n build_item_page_view_model, containment_crumb_chain, item_relations_markup,\n sibling_nav_markup,\n@@ -815,3 +816,39 @@ fn containment_breadcrumb_emits_leaf_hrefs() {\n );\n assert!(!html.contains(\"/~/x/\"));\n }\n+\n+#[test]\n+fn collect_question_entries_lists_scope_and_aspect_prompt() {\n+ let mut reduced = ReducerState::default();\n+ apply_ingest(\n+ &mut reduced,\n+ 1,\n+ \"~/psalms {Which psalm is greater?}\\n\\\n+ ~/psalms/a {alpha}\\n\\\n+ ~/psalms/b {beta}\\n\\\n+ :beauty {more beautiful}\\n\\\n+ ~/lonely {Nothing to judge yet}\\n\",\n+ );\n+ let content = content_for_garden_view(&reduced, &ScopeId::Public);\n+ let entries = collect_question_entries(&reduced, content, &ThreadNav::public());\n+ assert!(\n+ entries\n+ .iter()\n+ .any(|e| e.leaf == \"psalms\" && e.aspect.is_none()),\n+ \"canonical psalms missing: {:?}\",\n+ entries\n+ .iter()\n+ .map(|e| (e.leaf.clone(), e.aspect.clone()))\n+ .collect::>()\n+ );\n+ assert!(\n+ entries\n+ .iter()\n+ .any(|e| e.leaf == \"psalms\" && e.aspect.as_deref() == Some(\"beauty\")),\n+ \"aspect beauty missing\"\n+ );\n+ assert!(\n+ entries.iter().all(|e| e.leaf != \"lonely\"),\n+ \"lonely has no members and must not appear\"\n+ );\n+}\ndiff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs\nindex 84c476676faa3514ed04b4fdf387b8f910cdad4e..59f4bd657a068a35d806d7aace9e44d593f4c639 100644\n--- a/server/src/html/garden/vote.rs\n+++ b/server/src/html/garden/vote.rs\n@@ -31,9 +31,11 @@ use super::{\n user_can_view_room,\n },\n item::{item_display_path, login_href_with_next},\n+ question_body::{question_rankings, question_standings_region},\n };\n \n /// Prompt shown as the `/q/` headline (scope body, or restrained fallback copy).\n+#[derive(Debug, Clone)]\n pub(super) enum QuestionHeadline {\n Body(String),\n Fallback(String),\n@@ -49,11 +51,96 @@ pub(super) struct QuestionCtx {\n pub title: String,\n }\n \n+/// Unique element ids for a compare panel. Empty suffix is the `/q/` and `/vote` page.\n+#[derive(Debug, Clone, Default)]\n+pub(super) struct VoteCompareDomIds {\n+ pub suffix: Option,\n+}\n+\n+impl VoteCompareDomIds {\n+ pub(super) fn page() -> Self {\n+ Self { suffix: None }\n+ }\n+\n+ pub(super) fn with_suffix(suffix: impl Into) -> Self {\n+ let s = suffix.into();\n+ Self {\n+ suffix: (!s.is_empty()).then_some(s),\n+ }\n+ }\n+\n+ fn suffixed(&self, base: &str) -> String {\n+ match &self.suffix {\n+ Some(s) if !s.is_empty() => format!(\"{base}-{s}\"),\n+ _ => base.to_string(),\n+ }\n+ }\n+\n+ pub(super) fn form_id(&self) -> String {\n+ self.suffixed(\"vote-compare-form\")\n+ }\n+\n+ pub(super) fn history_id(&self) -> String {\n+ self.suffixed(\"vote-edge-history-region\")\n+ }\n+\n+ pub(super) fn nav_id(&self) -> String {\n+ self.suffixed(\"vote-compare-nav\")\n+ }\n+\n+ pub(super) fn slider_id(&self) -> String {\n+ self.suffixed(\"vote-preference-slider\")\n+ }\n+\n+ pub(super) fn ratio_left_id(&self) -> String {\n+ self.suffixed(\"vote-ratio-left\")\n+ }\n+\n+ pub(super) fn ratio_right_id(&self) -> String {\n+ self.suffixed(\"vote-ratio-right\")\n+ }\n+\n+ pub(super) fn readout_id(&self) -> String {\n+ self.suffixed(\"vote-ratio-readout\")\n+ }\n+\n+ pub(super) fn errors_id(&self) -> String {\n+ self.suffixed(\"vote-compare-errors\")\n+ }\n+\n+ pub(super) fn thread_select_id(&self) -> String {\n+ self.suffixed(\"vote-thread-select\")\n+ }\n+\n+ pub(super) fn slider_left_label_id(&self) -> String {\n+ self.suffixed(\"vote-slider-left-label\")\n+ }\n+\n+ pub(super) fn slider_right_label_id(&self) -> String {\n+ self.suffixed(\"vote-slider-right-label\")\n+ }\n+\n+ pub(super) fn explain_id(&self) -> String {\n+ self.suffixed(\"vote-explain\")\n+ }\n+}\n+\n fn is_question_href(path: &str) -> bool {\n let path = path.split(['?', '#']).next().unwrap_or(path);\n path.starts_with(\"/q/\") || path.contains(\"/q/\")\n }\n \n+/// `/q/:leaf` → `None`; `/q/:leaf/:aspect` and room-prefixed twins → `Some(aspect)`.\n+fn aspect_from_question_path(next: &str) -> Option {\n+ let path = next.split(['?', '#']).next().unwrap_or(next);\n+ let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();\n+ let q = parts.iter().position(|p| *p == \"q\")?;\n+ parts\n+ .get(q + 2)\n+ .filter(|s| !s.is_empty())\n+ .map(|s| (*s).to_string())\n+}\n+\n fn question_header_markup(\n question: &QuestionCtx,\n garden_root: &str,\n@@ -261,20 +348,32 @@ pub(crate) async fn vote_compare_post_success_js(\n _post_id: &str,\n _post_idx: Option,\n next: &str,\n+ dom_suffix: Option<&str>,\n ) -> String {\n+ let ids = VoteCompareDomIds::with_suffix(dom_suffix.unwrap_or(\"\").to_string());\n let reduced = state.reduced.read().await;\n let content = content_for_garden_view(&reduced, &nav.scope());\n let edge_history = vote_edge_history_markup(content, left, right);\n let next_pair = suggest_next_vote_pair(content, left, right, pool);\n let next_override = is_question_href(next).then_some(next);\n- let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool, next_override);\n+ let nav_markup =\n+ vote_compare_nav_markup(nav, next_pair.as_ref(), pool, next_override, &ids.nav_id());\n+ let standings = if is_question_href(next) {\n+ pool.map(|p| {\n+ let aspect = aspect_from_question_path(next);\n+ question_standings_region(&question_rankings(content, p, aspect.as_deref()), nav)\n+ })\n+ } else {\n+ None\n+ };\n drop(reduced);\n- JsBuilder::new()\n- .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n- .morph_selector(\".vote-compare-nav\", nav_markup)\n- .qs(\"#vote-compare-form\")\n- .reset()\n- .build()\n+ let mut js = JsBuilder::new()\n+ .morph_inner_selector(&format!(\"#{}\", ids.history_id()), edge_history)\n+ .morph_selector(&format!(\"#{}\", ids.nav_id()), nav_markup);\n+ if let Some(standings) = standings {\n+ js = js.morph_selector(\"#question-standings-region\", standings);\n+ }\n+ js.qs(&format!(\"#{}\", ids.form_id())).reset().build()\n }\n \n pub(super) fn vote_compare_href(\n@@ -320,6 +419,7 @@ fn vote_compare_nav_markup(\n next_pair: Option<&(ItemId, ItemId)>,\n pool: Option<&ItemId>,\n next_pair_href_override: Option<&str>,\n+ nav_id: &str,\n ) -> maud::Markup {\n let next_pair_href = if let Some(over) = next_pair_href_override.filter(|s| !s.is_empty()) {\n next_pair.and(Some(over.to_string()))\n@@ -327,7 +427,7 @@ fn vote_compare_nav_markup(\n next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool))\n };\n html! {\n- div class=\"vote-compare-nav\" {\n+ div id=(nav_id) class=\"vote-compare-nav\" {\n @if let Some(href) = &next_pair_href {\n a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n } @else {\n@@ -393,6 +493,213 @@ pub(super) fn vote_compare_item_card(\n }\n }\n }\n+pub(super) struct VoteComparePanel<'a> {\n+ pub nav: &'a ThreadNav,\n+ pub left: &'a ItemId,\n+ pub right: &'a ItemId,\n+ pub left_body: Option<&'a String>,\n+ pub right_body: Option<&'a String>,\n+ pub item_bodies: Option<&'a HashMap>,\n+ pub pool: Option<&'a ItemId>,\n+ pub auto_thread: &'a str,\n+ pub thread_tags: &'a [String],\n+ pub edge_history: maud::Markup,\n+ pub next_pair: Option<&'a (ItemId, ItemId)>,\n+ pub next_path: &'a str,\n+ pub next_pair_override: Option<&'a str>,\n+ pub aspect_slug: Option<&'a str>,\n+ pub logged_in: bool,\n+ pub show_vote_form: bool,\n+ pub question: Option<&'a QuestionCtx>,\n+ pub include_heading: bool,\n+ pub ids: &'a VoteCompareDomIds,\n+}\n+\n+/// Pair cards + nav + history + vote form. Shared by `/q/` and `/vote`.\n+pub(super) fn vote_compare_panel_markup(p: VoteComparePanel<'_>) -> maud::Markup {\n+ let mut rpc_val = json!({\n+ \"action\": \"vote_compare_post\",\n+ \"room\": p.nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"left_item\": p.left.as_str(),\n+ \"right_item\": p.right.as_str(),\n+ \"ratio_left\": {\"$form\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form\": \"ratio_right\"},\n+ \"explanation\": {\"$form\": \"explanation\"},\n+ \"next\": p.next_path,\n+ \"pool\": p.pool.map(|q| q.as_str()),\n+ \"form_action\": \"/ui\",\n+ });\n+ if p.aspect_slug.is_some() {\n+ rpc_val[\"aspect\"] = json!({\"$form\": \"aspect\"});\n+ }\n+ if let Some(suffix) = p.ids.suffix.as_deref().filter(|s| !s.is_empty()) {\n+ rpc_val[\"dom_suffix\"] = json!(suffix);\n+ }\n+ let rpc_json = template_json_compact(&rpc_val).expect(\"vote compare rpc json\");\n+ let form_id = p.ids.form_id();\n+ let history_id = p.ids.history_id();\n+ let nav_id = p.ids.nav_id();\n+ let slider_id = p.ids.slider_id();\n+ let ratio_left_id = p.ids.ratio_left_id();\n+ let ratio_right_id = p.ids.ratio_right_id();\n+ let readout_id = p.ids.readout_id();\n+ let errors_id = p.ids.errors_id();\n+ let thread_select_id = p.ids.thread_select_id();\n+ let slider_left_id = p.ids.slider_left_label_id();\n+ let slider_right_id = p.ids.slider_right_label_id();\n+ let explain_id = p.ids.explain_id();\n+ html! {\n+ section class=\"vote-compare-shell\" {\n+ @if p.include_heading {\n+ @if let Some(qctx) = p.question {\n+ (question_header_markup(qctx, p.nav.garden_root_url(), p.item_bodies))\n+ } @else {\n+ h2 { \"compare\" }\n+ }\n+ }\n+ div class=\"vote-compare-pair\" {\n+ (vote_compare_item_card(\n+ p.nav,\n+ p.left,\n+ p.left_body,\n+ \"vote-compare-left\",\n+ p.item_bodies,\n+ ))\n+ span class=\"vote-compare-vs\" { \"vs\" }\n+ (vote_compare_item_card(\n+ p.nav,\n+ p.right,\n+ p.right_body,\n+ \"vote-compare-right\",\n+ p.item_bodies,\n+ ))\n+ }\n+ (vote_compare_nav_markup(\n+ p.nav,\n+ p.next_pair,\n+ p.pool,\n+ p.next_pair_override,\n+ &nav_id,\n+ ))\n+ div id=(history_id) {\n+ (p.edge_history)\n+ }\n+ @if p.show_vote_form && p.logged_in {\n+ form id=(form_id) class=\"vote-compare-form\" method=\"POST\" action=\"/ui\" data-draft-key=(format!(\"vote:{}/{}/{}\", p.nav.room_wire, p.left.as_str(), p.right.as_str())) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+ @if let Some(aspect) = p.aspect_slug {\n+ input type=\"hidden\" name=\"aspect\" value=(aspect);\n+ }\n+ div class=\"vote-thread-picker\" {\n+ label class=\"vote-thread-picker-label\" { \"thread\" }\n+ select id=(thread_select_id) name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n+ @if p.thread_tags.is_empty() {\n+ option value=\"vote\" selected { \"#vote\" }\n+ }\n+ @for t in p.thread_tags {\n+ @if t == p.auto_thread {\n+ option value=(t) selected { \"#\" (t) }\n+ } @else {\n+ option value=(t) { \"#\" (t) }\n+ }\n+ }\n+ }\n+ }\n+ input type=\"hidden\" name=\"ratio_left\" id=(ratio_left_id) value=\"1\";\n+ input type=\"hidden\" name=\"ratio_right\" id=(ratio_right_id) value=\"1\";\n+ p class=\"vote-ratio-readout-wrap\" {\n+ span class=\"vote-ratio-readout-label muted\" { \"ratio\" }\n+ \" \"\n+ span id=(readout_id) class=\"vote-ratio-readout\" aria-live=\"polite\" { \"1:1\" }\n+ }\n+ label class=\"vote-compare-slider-label\" {\n+ span id=(slider_left_id) { (item_display_path(p.left.as_str())) }\n+ input type=\"range\" id=(slider_id) class=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+ aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuetext=\"1:1\";\n+ span id=(slider_right_id) { (item_display_path(p.right.as_str())) }\n+ }\n+ label class=\"vote-explain-label\" { \"reason (required)\" }\n+ textarea name=\"explanation\" id=(explain_id) rows=\"5\" placeholder=\"why this split?\" required {}\n+ div id=(errors_id) {}\n+ p { button type=\"submit\" { \"post vote\" } }\n+ }\n+ } @else if p.show_vote_form {\n+ // Guest CTA is outside any form so click is a normal navigation to login.\n+ div id=(form_id) class=\"vote-compare-form vote-compare-guest\" {\n+ p {\n+ a class=\"vote-compare-login-cta\" href=(login_href_with_next(p.next_path)) { \"post vote\" }\n+ }\n+ p class=\"muted\" { \"you’ll log in, then return to this pair to cast your vote.\" }\n+ }\n+ } @else {\n+ p class=\"muted\" { \"you need post access in this room to vote on this pair.\" }\n+ }\n+ }\n+ }\n+}\n+\n+/// Build the compare panel for a garden scope (and optional aspect), or empty markup if no pair.\n+pub(super) fn question_vote_panel(\n+ content: &ContentState,\n+ nav: &ThreadNav,\n+ collection: &ItemId,\n+ aspect: Option<&str>,\n+ logged_in: bool,\n+ can_post: bool,\n+ next_path: &str,\n+ next_pair_override: Option<&str>,\n+ question: Option<&QuestionCtx>,\n+ include_heading: bool,\n+ ids: &VoteCompareDomIds,\n+) -> maud::Markup {\n+ let members = comparable_items(content, content.members_of(collection));\n+ if members.len() < 2 {\n+ return html! {\n+ p class=\"muted vote-question-empty\" {\n+ \"nothing to compare yet — this scope needs at least two items with bodies.\"\n+ }\n+ };\n+ }\n+ let Some((left, right)) = suggest_next_pair_in_pool(&content.ranking_group, &members, None)\n+ else {\n+ return html! {\n+ p class=\"muted vote-question-empty\" { \"no pairs available in this scope.\" }\n+ };\n+ };\n+ let auto_thread = canonicalize_tag(&collection.last_segment());\n+ let mut thread_tags = vote_thread_tags_for_pair(content, &left, &right);\n+ if !auto_thread.is_empty() && !thread_tags.iter().any(|t| t == &auto_thread) {\n+ thread_tags.insert(0, auto_thread.clone());\n+ }\n+ let edge_history = vote_edge_history_markup(content, &left, &right);\n+ let left_body = content.item_bodies.get(&left);\n+ let right_body = content.item_bodies.get(&right);\n+ let next_pair = suggest_next_vote_pair(content, &left, &right, Some(collection));\n+ let show_vote_form = can_post || !logged_in;\n+ vote_compare_panel_markup(VoteComparePanel {\n+ nav,\n+ left: &left,\n+ right: &right,\n+ left_body,\n+ right_body,\n+ item_bodies: Some(&content.item_bodies),\n+ pool: Some(collection),\n+ auto_thread: &auto_thread,\n+ thread_tags: &thread_tags,\n+ edge_history,\n+ next_pair: next_pair.as_ref(),\n+ next_path,\n+ next_pair_override,\n+ aspect_slug: aspect.filter(|s| !s.is_empty()),\n+ logged_in,\n+ show_vote_form,\n+ question,\n+ include_heading,\n+ ids,\n+ })\n+}\n+\n #[derive(Debug, Deserialize)]\n pub struct VoteCompareQuery {\n #[serde(default)]\n@@ -561,110 +868,34 @@ pub(super) async fn vote_compare_inner(\n .as_ref()\n .and_then(|q| q.aspect.clone())\n .filter(|s| !s.is_empty());\n- let mut rpc_val = json!({\n- \"action\": \"vote_compare_post\",\n- \"room\": nav.room_wire,\n- \"thread_tag\": {\"$form\": \"thread_tag\"},\n- \"left_item\": left.as_str(),\n- \"right_item\": right.as_str(),\n- \"ratio_left\": {\"$form\": \"ratio_left\"},\n- \"ratio_right\": {\"$form\": \"ratio_right\"},\n- \"explanation\": {\"$form\": \"explanation\"},\n- \"next\": next_path,\n- \"pool\": pool_id.as_ref().map(|p| p.as_str()),\n- \"form_action\": \"/ui\",\n- });\n- if aspect_slug.is_some() {\n- rpc_val[\"aspect\"] = json!({\"$form\": \"aspect\"});\n- }\n- let rpc_json = template_json_compact(&rpc_val).expect(\"vote compare rpc json\");\n- let next_pair_override = question.as_ref().map(|q| q.question_path.as_str());\n+ let next_pair_override = question.as_ref().map(|q| q.question_path.clone());\n let view_class = if question.is_some() {\n \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen view-vote-question\"\n } else {\n \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen\"\n };\n \n- let body = html! {\n- section class=\"vote-compare-shell\" {\n- @if let Some(qctx) = &question {\n- (question_header_markup(qctx, nav.garden_root_url(), Some(&item_bodies_for_cards)))\n- } @else {\n- h2 { \"compare\" }\n- }\n- div class=\"vote-compare-pair\" {\n- (vote_compare_item_card(\n- &nav,\n- &left,\n- left_body.as_ref(),\n- \"vote-compare-left\",\n- Some(&item_bodies_for_cards),\n- ))\n- span class=\"vote-compare-vs\" { \"vs\" }\n- (vote_compare_item_card(\n- &nav,\n- &right,\n- right_body.as_ref(),\n- \"vote-compare-right\",\n- Some(&item_bodies_for_cards),\n- ))\n- }\n- (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref(), next_pair_override))\n- div id=\"vote-edge-history-region\" {\n- (edge_history)\n- }\n- @if show_vote_form && logged_in {\n- form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" data-draft-key=(format!(\"vote:{}/{}/{}\", nav.room_wire, left.as_str(), right.as_str())) {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n- @if let Some(aspect) = &aspect_slug {\n- input type=\"hidden\" name=\"aspect\" value=(aspect);\n- }\n- div class=\"vote-thread-picker\" {\n- label class=\"vote-thread-picker-label\" { \"thread\" }\n- select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n- @if thread_tags.is_empty() {\n- option value=\"vote\" selected { \"#vote\" }\n- }\n- @for t in &thread_tags {\n- @if *t == auto_thread {\n- option value=(t) selected { \"#\" (t) }\n- } @else {\n- option value=(t) { \"#\" (t) }\n- }\n- }\n- }\n- }\n- input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"1\";\n- input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"1\";\n- p class=\"vote-ratio-readout-wrap\" {\n- span class=\"vote-ratio-readout-label muted\" { \"ratio\" }\n- \" \"\n- span id=\"vote-ratio-readout\" class=\"vote-ratio-readout\" aria-live=\"polite\" { \"1:1\" }\n- }\n- label class=\"vote-compare-slider-label\" {\n- span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n- input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n- aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuetext=\"1:1\";\n- span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n- }\n- label class=\"vote-explain-label\" { \"reason (required)\" }\n- textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n- div id=\"vote-compare-errors\" {}\n- p { button type=\"submit\" { \"post vote\" } }\n- }\n- } @else if show_vote_form {\n- // Guest CTA is outside any form so click is a normal navigation to login.\n- div id=\"vote-compare-form\" class=\"vote-compare-guest\" {\n- p {\n- a class=\"vote-compare-login-cta\" href=(login_href_with_next(&next_path)) { \"post vote\" }\n- }\n- p class=\"muted\" { \"you’ll log in, then return to this pair to cast your vote.\" }\n- }\n- } @else {\n- p class=\"muted\" { \"you need post access in this room to vote on this pair.\" }\n- }\n- }\n- };\n+ let body = vote_compare_panel_markup(VoteComparePanel {\n+ nav: &nav,\n+ left: &left,\n+ right: &right,\n+ left_body: left_body.as_ref(),\n+ right_body: right_body.as_ref(),\n+ item_bodies: Some(&item_bodies_for_cards),\n+ pool: pool_id.as_ref(),\n+ auto_thread: &auto_thread,\n+ thread_tags: &thread_tags,\n+ edge_history,\n+ next_pair: next_pair.as_ref(),\n+ next_path: &next_path,\n+ next_pair_override: next_pair_override.as_deref(),\n+ aspect_slug: aspect_slug.as_deref(),\n+ logged_in,\n+ show_vote_form,\n+ question: question.as_ref(),\n+ include_heading: true,\n+ ids: &VoteCompareDomIds::page(),\n+ });\n \n let url_key = canonical_view_url(&uri);\n let view_count = state.views.get_views(&url_key);\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex d8666d3a340d9acc78d749a6a7a6f11f39ea87a9..092ee21c2fa46db9ab328accd9dd67fdd474c210 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -26,8 +26,8 @@ pub use auth::{\n };\n pub use editor::{editor_check, editor_page};\n pub use forum::{\n- home, room_page, room_thread_post_view, room_thread_view, thread_feed_html,\n- thread_feed_html_for_room, thread_post_view, thread_view, ThreadNav,\n+ room_page, room_thread_post_view, room_thread_view, thread_feed_html,\n+ thread_feed_html_for_room, thread_index, thread_post_view, thread_view, ThreadNav,\n };\n pub(crate) use forum::{\n thread_latest_page_region, thread_region_page_morphs, ThreadRegionPageMorphs,\n@@ -45,7 +45,7 @@ pub(crate) use garden::{\n vote_compare_post_success_js, GARDEN_PIN_COOKIE,\n };\n pub use garden::{\n- external_garden_index, external_ontology_path, garden_index, ontology_path,\n+ external_garden_index, external_ontology_path, garden_index, home, ontology_path,\n question_aspect_page, question_page, redirect_strip_trailing_slash, room_external_garden_index,\n room_external_ontology_path, room_garden_index, room_ontology_path, room_question_aspect_page,\n room_question_page, room_vote_compare_page, vote_compare_page,\n@@ -608,20 +608,15 @@ fn bc_path(path: &OntologyPath) -> Markup {\n }\n }\n \n-/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post.\n-/// Root link toggles to `/~` only at thread-root (`/`).\n-/// When focusing a post, `#tag` links to the thread page that contains it (`?offset=` + `#post-N`).\n+/// Render the thread path breadcrumb: `slug.social / threads` on `/t`, or `… / #tag` / `… / post #N`.\n+/// Brand always points at home (`/`). When focusing a post, `#tag` links to the thread page\n+/// that contains it (`?offset=` + `#post-N`).\n pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup {\n- let root_href = if thread_tag.is_some() { \"/\" } else { \"/~\" };\n- let root_is_current = thread_tag.is_none();\n let nav = ThreadNav::public();\n html! {\n- @if root_is_current {\n- a href=(root_href) class=\"bc-current\" { \"slug.social\" }\n- } @else {\n- a href=(root_href) { \"slug.social\" }\n- }\n+ a href=\"/\" { \"slug.social\" }\n @if let Some(tag) = thread_tag {\n+ (bc_segment(\"threads\", \"/t\", false))\n @if let Some(idx) = focused_post {\n (bc_segment(\n &format!(\"#{tag}\"),\n@@ -632,6 +627,8 @@ pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option)\n } @else {\n (bc_segment(&format!(\"#{tag}\"), &nav.thread_url(tag), true))\n }\n+ } @else {\n+ (bc_segment(\"threads\", \"/t\", true))\n }\n }\n }\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 83dbdbd71fae74654c24326ae5928b1638bbd084..5ac78371e3ea96d314b61dfea60b64d0de262ca5 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -57,6 +57,9 @@ pub enum HtmlUiAction {\n /// Empty / omitted → canonical ranking (same as `/vote`).\n #[serde(default)]\n aspect: Option,\n+ /// Distinguishes home-index rows from the `/q/` page (`vote-compare-form-{suffix}`).\n+ #[serde(default)]\n+ dom_suffix: Option,\n #[serde(default = \"default_ui_form_action\")]\n form_action: String,\n },\n@@ -92,7 +95,7 @@ pub enum HtmlUiAction {\n },\n /// Delete the private room (Manage only); redirects to `/` on success.\n DeleteRoom { room: String },\n- /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n+ /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for `/t`).\n SetNewThreadComposeExpanded {\n room_wire: String,\n #[serde(default)]\n@@ -357,6 +360,7 @@ mod tests {\n next: \"/q/psalms/beauty\".into(),\n pool: None,\n aspect: Some(\"beauty\".into()),\n+ dom_suffix: None,\n form_action: \"/ui\".into(),\n }\n );\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex a96322df85c6b289d5e89f7092b4f3f0bee4ad76..21c92c2532effc32b6d787d3b30d4cc6ba02aa9c 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -75,6 +75,8 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/healthz\", get(|| async { \"ok\" }))\n .route(\"/static/:filename\", get(crate::html::serve_static))\n .route(\"/\", get(crate::html::home))\n+ .route(\"/t\", get(crate::html::thread_index))\n+ .route(\"/t/\", get(crate::html::redirect_strip_trailing_slash))\n .route(\"/login\", get(api::get_web_login))\n .route(\"/logout\", get(api::get_logout))\n .route(\"/ui\", post(api::post_ui_html))\ndiff --git a/server/static/slug_ui.js b/server/static/slug_ui.js\nindex 17420f37d3e656189c7944857dcdb2037de0d0ff..f6da7f80fe5b5d61add8cbc64b6d72e0d3c1005f 100644\n--- a/server/static/slug_ui.js\n+++ b/server/static/slug_ui.js\n@@ -31,7 +31,7 @@\n if (el.type === 'checkbox' && !el.checked) return;\n data[el.name] = el.value;\n });\n- var slider = container.querySelector('#vote-preference-slider');\n+ var slider = container.querySelector('.vote-preference-slider');\n if (slider) data.__slider = slider.value;\n return data;\n }\n@@ -71,7 +71,7 @@\n }\n \n function syncVoteSliderFromDraft(form, data) {\n- var slider = form.querySelector('#vote-preference-slider');\n+ var slider = form.querySelector('.vote-preference-slider');\n if (!slider) return;\n if (data.__slider != null && String(data.__slider).trim() !== '') {\n slider.value = data.__slider;\n@@ -368,28 +368,31 @@\n }\n refreshPinHud();\n \n- // Vote compare: map slider 0–100 to reduced integer ratio weights\n- var voteSlider = document.getElementById('vote-preference-slider');\n- if (voteSlider) {\n- var rl = document.getElementById('vote-ratio-left');\n- var rr = document.getElementById('vote-ratio-right');\n- var readout = document.getElementById('vote-ratio-readout');\n- function gcd(a, b) {\n- a = Math.abs(a | 0);\n- b = Math.abs(b | 0);\n- while (b) {\n- var t = b;\n- b = a % b;\n- a = t;\n- }\n- return a || 1;\n- }\n- function reduceRatio(left, right) {\n- var L = Math.max(1, left | 0);\n- var R = Math.max(1, right | 0);\n- var g = gcd(L, R);\n- return [L / g, R / g];\n+ // Vote compare: map slider 0–100 to reduced integer ratio weights.\n+ // Home index can render several forms; bind each slider inside its form.\n+ function gcd(a, b) {\n+ a = Math.abs(a | 0);\n+ b = Math.abs(b | 0);\n+ while (b) {\n+ var t = b;\n+ b = a % b;\n+ a = t;\n }\n+ return a || 1;\n+ }\n+ function reduceRatio(left, right) {\n+ var L = Math.max(1, left | 0);\n+ var R = Math.max(1, right | 0);\n+ var g = gcd(L, R);\n+ return [L / g, R / g];\n+ }\n+ function bindVoteSlider(voteSlider) {\n+ if (!voteSlider || voteSlider.getAttribute('data-slug-bound')) return;\n+ voteSlider.setAttribute('data-slug-bound', '1');\n+ var root = voteSlider.closest('form') || voteSlider.closest('.vote-compare-shell');\n+ var rl = root ? root.querySelector('input[name=\"ratio_left\"]') : null;\n+ var rr = root ? root.querySelector('input[name=\"ratio_right\"]') : null;\n+ var readout = root ? root.querySelector('.vote-ratio-readout') : null;\n function syncVoteRatio() {\n var p = parseInt(voteSlider.value, 10);\n if (isNaN(p)) p = 50;\n@@ -407,6 +410,7 @@\n voteSlider.addEventListener('input', syncVoteRatio);\n syncVoteRatio();\n }\n+ document.querySelectorAll('input.vote-preference-slider').forEach(bindVoteSlider);\n \n // SSE: server-pushed JS\n function connectSSE() {\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex b0f4370037af4a5bc215b655697ac95440bf6951..7e5d610aa413880208ac2ea577d4263022d4fae8 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -1284,6 +1284,58 @@ body.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n .vote-question-empty {\n padding: 0 16px 16px;\n }\n+body.view-vote-question.view-vote-compare-fullscreen .vote-compare-shell {\n+ min-height: 0;\n+}\n+.vote-question-page {\n+ max-width: 720px;\n+ margin: 0 auto;\n+ padding: 0 0 32px;\n+}\n+.question-aspects,\n+.question-standings,\n+.question-members {\n+ padding: 8px 16px 16px;\n+}\n+.question-aspects h2,\n+.question-standings h2,\n+.question-members h2 {\n+ font-size: 12px;\n+ letter-spacing: 0.06em;\n+ text-transform: uppercase;\n+ color: var(--meta);\n+ margin: 16px 0 8px;\n+}\n+.question-standings-list {\n+ margin: 0;\n+ padding-left: 1.5em;\n+}\n+.question-judge-this {\n+ font-weight: 600;\n+}\n+.home-intro {\n+ margin: 0 0 1.25rem;\n+}\n+.home-lede {\n+ margin: 0.35rem 0;\n+}\n+.home-nav-links {\n+ margin: 0.25rem 0 0;\n+}\n+.question-index-entry {\n+ margin: 0 0 1rem;\n+}\n+.question-row-summary {\n+ cursor: pointer;\n+}\n+.question-preview {\n+ margin: 0.6rem 0 0 0.4rem;\n+}\n+.question-index-aspects {\n+ margin: 0.35rem 0 0 1.2rem;\n+ padding: 0;\n+ list-style: disc;\n+}\n \n .vote-edge-history-title {\n font-size: 12px;\n@@ -1890,3 +1942,50 @@ ul.import-card__meta {\n max-height: 360px;\n border-radius: 4px;\n }\n+\n+/* Home question index */\n+.home-intro {\n+ margin: 16px 0 12px;\n+}\n+.home-intro h1 {\n+ margin: 0 0 4px;\n+ font-size: 22px;\n+}\n+.home-lede {\n+ margin: 0 0 8px;\n+ color: var(--prose);\n+}\n+.home-nav-links a { color: var(--link); }\n+.question-index {\n+ display: flex;\n+ flex-direction: column;\n+ gap: 8px;\n+ margin: 12px 0 24px;\n+}\n+details.question-row {\n+ background: var(--g2);\n+ border: var(--bv) solid;\n+ border-color: var(--hi) var(--lo) var(--lo) var(--hi);\n+ padding: 0;\n+}\n+details.question-row > summary {\n+ cursor: pointer;\n+ list-style: none;\n+ padding: 8px 12px;\n+ color: var(--signal);\n+}\n+details.question-row > summary::-webkit-details-marker { display: none; }\n+details.question-row > summary::before {\n+ content: \"▸ \";\n+ color: var(--ui);\n+}\n+details.question-row[open] > summary::before { content: \"▾ \"; }\n+.question-row-title { font-weight: 600; }\n+.question-row-links {\n+ margin: 0 12px 8px;\n+ font-size: 13px;\n+}\n+details.question-row .vote-compare-shell {\n+ margin: 0 8px 10px;\n+ max-width: none;\n+}\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex 25dabcee7c33731cf7c8663dde92901bfe706487..f20bb1e59c73931a6f86e20bb2759ae08e9ec402 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -327,6 +327,42 @@ body.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n .vote-question-empty {\n padding: 0 1rem 1rem;\n }\n+body.view-vote-question.view-vote-compare-fullscreen .vote-compare-shell {\n+ min-height: 0;\n+}\n+.vote-question-page {\n+ max-width: 720px;\n+ margin: 0 auto;\n+ padding: 0 0 2rem;\n+}\n+.question-aspects,\n+.question-standings,\n+.question-members {\n+ padding: 0.5rem 1rem 1rem;\n+}\n+.question-aspects h2,\n+.question-standings h2,\n+.question-members h2 {\n+ font-size: 0.75rem;\n+ letter-spacing: 0.06em;\n+ text-transform: uppercase;\n+ margin: 1rem 0 0.5rem;\n+}\n+.question-judge-this {\n+ font-weight: 600;\n+}\n+.home-intro {\n+ margin: 0 0 1.25rem;\n+}\n+.question-index-entry {\n+ margin: 0 0 1rem;\n+}\n+.question-preview {\n+ margin: 0.6rem 0 0 0.4rem;\n+}\n+.question-index-aspects {\n+ margin: 0.35rem 0 0 1.2rem;\n+}\n body.view-ontology .vote-compare-pair {\n align-items: start;\n display: grid;\n@@ -420,3 +456,6 @@ body.view-ontology .vote-compare-item-body .item-body-rich article.import-card {\n body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.import-card {\n margin-left: auto;\n }\n+\n+details.question-row > summary { cursor: pointer; }\n+.question-row-links { font-size: 13px; }\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex feddfc868fc2985cc8c7b6d276b35694d699e92a..6603aa89faf4e62b7a503cd3cc17e6763d3266f7 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -938,6 +938,42 @@ body.view-ontology.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n .vote-question-empty {\n padding: 0 1rem 1rem;\n }\n+body.view-vote-question.view-vote-compare-fullscreen .vote-compare-shell {\n+ min-height: 0;\n+}\n+.vote-question-page {\n+ max-width: 720px;\n+ margin: 0 auto;\n+ padding: 0 0 2rem;\n+}\n+.question-aspects,\n+.question-standings,\n+.question-members {\n+ padding: 0.5rem 1rem 1rem;\n+}\n+.question-aspects h2,\n+.question-standings h2,\n+.question-members h2 {\n+ font-size: 0.75rem;\n+ letter-spacing: 0.06em;\n+ text-transform: uppercase;\n+ margin: 1rem 0 0.5rem;\n+}\n+.question-judge-this {\n+ font-weight: 600;\n+}\n+.home-intro {\n+ margin: 0 0 1.25rem;\n+}\n+.question-index-entry {\n+ margin: 0 0 1rem;\n+}\n+.question-preview {\n+ margin: 0.6rem 0 0 0.4rem;\n+}\n+.question-index-aspects {\n+ margin: 0.35rem 0 0 1.2rem;\n+}\n body.view-ontology .vote-compare-pair {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n@@ -1246,3 +1282,11 @@ body.view-ontology .vote-compare-preview-wrap h3 {\n body.view-ontology #vote-compare-preview {\n min-height: 2.5rem;\n }\n+\n+.home-intro { margin: 1rem 0; }\n+details.question-row {\n+ border: 1px solid #c4b89a;\n+ margin: 0.4rem 0;\n+ padding: 0.2rem 0.5rem;\n+}\n+details.question-row > summary { cursor: pointer; }\ndiff --git a/server/tests/integration_health.rs b/server/tests/integration_health.rs\nindex 14d71b711c72de5c6f925135adb66be622074cf0..d58814fdf4a1806d7d29f4b82fe78448154e349e 100644\n--- a/server/tests/integration_health.rs\n+++ b/server/tests/integration_health.rs\n@@ -61,6 +61,29 @@ async fn test_home_header_shows_post_stats() {\n home.chars().take(800).collect::()\n );\n assert!(home.contains(\"view-meta\"));\n+ assert!(\n+ home.contains(\"home-intro\") || home.contains(\"question-index\"),\n+ \"GET / should be the question index, snippet={}\",\n+ home.chars().take(800).collect::()\n+ );\n+\n+ let threads = client\n+ .get(format!(\"http://{addr}/t\"))\n+ .send()\n+ .await\n+ .unwrap()\n+ .text()\n+ .await\n+ .unwrap();\n+ assert!(\n+ threads.contains(\"3 human posts, 1 ai post\"),\n+ \"thread index should keep post stats, snippet={}\",\n+ threads.chars().take(800).collect::()\n+ );\n+ assert!(\n+ threads.contains(\"thread-feed\"),\n+ \"GET /t should serve the thread list\"\n+ );\n }\n \n #[tokio::test]\ndiff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs\nindex 8f895001b646196873004be7273ca19cb58761f2..cc213eaf4ac6cd103ad0403bcfece282a7df41d0 100644\n--- a/server/tests/integration_html.rs\n+++ b/server/tests/integration_html.rs\n@@ -289,7 +289,11 @@ fn question_garden_ingest(thread_tag: &str) -> Event {\n ~/psalms {Which psalm is greater?}\\n\\\n ~/psalms/psalm-23 {The Lord is my shepherd}\\n\\\n ~/psalms/psalm-1 {Blessed is the man}\\n\\\n+{canonical}\\n\\\n+~/psalms/psalm-23 3:1 ~/psalms/psalm-1\\n\\\n :beauty {more beautiful}\\n\\\n+{more beautiful}\\n\\\n+~/psalms/psalm-23 2:1 ~/psalms/psalm-1\\n\\\n ~/lonely {Nothing to judge yet}\\n\"\n .to_string(),\n principal: \"testuser\".to_string(),\n@@ -506,3 +510,185 @@ async fn test_room_question_page_uses_room_prefixes() {\n assert!(body.contains(\"value=\\\"beauty\\\"\"));\n assert!(body.contains(\"value=\\\"psalms\\\"\"));\n }\n+\n+#[tokio::test]\n+async fn test_question_page_has_standings_members_and_aspects() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ {\n+ let mut w = state.reduced.write().await;\n+ w.apply_event(question_garden_ingest(\"psalms-seed\"));\n+ }\n+ let client = reqwest::Client::new();\n+ let body = client\n+ .get(format!(\"http://{addr}/q/psalms\"))\n+ .send()\n+ .await\n+ .unwrap()\n+ .text()\n+ .await\n+ .unwrap();\n+ assert!(\n+ body.contains(\"data-testid=\\\"question-standings\\\"\"),\n+ \"standings missing: {}\",\n+ body.chars().take(2000).collect::()\n+ );\n+ assert!(\n+ body.contains(\"data-testid=\\\"question-members\\\"\"),\n+ \"members missing\"\n+ );\n+ assert!(\n+ body.contains(\"psalm-23\") && body.contains(\"psalm-1\"),\n+ \"collection members missing: {}\",\n+ body.chars().take(2000).collect::()\n+ );\n+ assert!(\n+ body.contains(\"href=\\\"/q/psalms/beauty\\\"\"),\n+ \"aspect link missing: {}\",\n+ body.chars().take(2000).collect::()\n+ );\n+ assert!(body.contains(\"id=\\\"vote-edge-history-region\\\"\"));\n+ assert!(body.contains(\"vote-compare-nav\"));\n+}\n+\n+#[tokio::test]\n+async fn test_question_aspect_page_links_back_to_canonical() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ {\n+ let mut w = state.reduced.write().await;\n+ w.apply_event(question_garden_ingest(\"psalms-seed\"));\n+ }\n+ let client = reqwest::Client::new();\n+ let body = client\n+ .get(format!(\"http://{addr}/q/psalms/beauty\"))\n+ .send()\n+ .await\n+ .unwrap()\n+ .text()\n+ .await\n+ .unwrap();\n+ assert!(\n+ body.contains(\"data-testid=\\\"question-canonical-link\\\"\"),\n+ \"canonical back-link missing: {}\",\n+ body.chars().take(1500).collect::()\n+ );\n+ assert!(body.contains(\"href=\\\"/q/psalms\\\"\"));\n+ assert!(body.contains(\"data-testid=\\\"question-standings\\\"\"));\n+}\n+\n+#[tokio::test]\n+async fn test_home_is_question_index_with_expandable_seeded_forms() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ {\n+ let mut w = state.reduced.write().await;\n+ w.apply_event(question_garden_ingest(\"psalms-seed\"));\n+ }\n+ let client = reqwest::Client::new();\n+ let guest = client.get(format!(\"http://{addr}/\")).send().await.unwrap();\n+ assert!(guest.status().is_success(), \"{}\", guest.status());\n+ let guest_body = guest.text().await.unwrap();\n+ assert!(\n+ guest_body.contains(\"data-testid=\\\"question-index\\\"\"),\n+ \"question index missing: {}\",\n+ guest_body.chars().take(1500).collect::()\n+ );\n+ assert!(guest_body.contains(\"Which psalm is greater?\"));\n+ assert!(guest_body.contains(\"more beautiful\"));\n+ assert!(guest_body.contains(\"data-question-leaf=\\\"psalms\\\"\"));\n+ assert!(guest_body.contains(\"data-question-aspect=\\\"beauty\\\"\"));\n+ assert!(guest_body.contains(\"href=\\\"/t\\\"\"));\n+ assert!(guest_body.contains(\"href=\\\"/~\\\"\"));\n+ assert!(guest_body.contains(\"vote-compare-pair\"));\n+ let login_href = format!(\"/login?next={}\", urlencoding::encode(\"/q/psalms\"));\n+ assert!(\n+ guest_body.contains(&login_href),\n+ \"guest CTA should return to /q/psalms: {}\",\n+ guest_body.chars().take(2500).collect::()\n+ );\n+ assert!(!guest_body.contains(\"id=\\\"thread-feed\\\"\"));\n+ assert!(!guest_body.contains(\"id=\\\"new-thread-ui-slot\\\"\"));\n+\n+ let authed = client\n+ .get(format!(\"http://{addr}/\"))\n+ .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(authed.status().is_success(), \"{}\", authed.status());\n+ let body = authed.text().await.unwrap();\n+ assert!(\n+ body.contains(\"value=\\\"psalms\\\"\"),\n+ \"thread_tag should be seeded on the home form: {}\",\n+ body.chars().take(2500).collect::()\n+ );\n+ assert!(body.contains(\"name=\\\"aspect\\\"\"));\n+ assert!(\n+ body.contains(\"value=\\\"beauty\\\"\"),\n+ \"aspect field missing on home: {}\",\n+ body.chars().take(2500).collect::()\n+ );\n+}\n+\n+#[tokio::test]\n+async fn test_home_empty_garden_has_honest_empty_state() {\n+ let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::new();\n+ let resp = client.get(format!(\"http://{addr}/\")).send().await.unwrap();\n+ assert!(resp.status().is_success(), \"{}\", resp.status());\n+ let body = resp.text().await.unwrap();\n+ assert!(body.contains(\"data-testid=\\\"question-index-empty\\\"\"));\n+ assert!(body.contains(\"href=\\\"/t\\\"\"));\n+ assert!(body.contains(\"href=\\\"/~\\\"\"));\n+ assert!(!body.contains(\"data-testid=\\\"question-index\\\"\"));\n+}\n+\n+#[tokio::test]\n+async fn test_thread_index_serves_former_home_forum() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ {\n+ let mut w = state.reduced.write().await;\n+ w.apply_event(Event::Ingest(Ingest {\n+ ts: 5,\n+ id: \"ing-thread-index\".into(),\n+ raw: \"hello from the forum index\".into(),\n+ principal: \"testuser\".into(),\n+ delegate: None,\n+ room_id: \"public\".into(),\n+ thread_tag: \"forum-home\".into(),\n+ }));\n+ }\n+ let client = reqwest::Client::new();\n+ let resp = client.get(format!(\"http://{addr}/t\")).send().await.unwrap();\n+ assert!(resp.status().is_success(), \"{}\", resp.status());\n+ let body = resp.text().await.unwrap();\n+ assert!(\n+ body.contains(\"id=\\\"thread-feed\\\"\"),\n+ \"thread index should keep the bump list: {}\",\n+ body.chars().take(1500).collect::()\n+ );\n+ assert!(body.contains(\"forum-home\") || body.contains(\"#forum-home\"));\n+ assert!(body.contains(\"id=\\\"new-thread-ui-slot\\\"\") || body.contains(\"log in to post\"));\n+ assert!(\n+ body.contains(\"3 human posts\") || body.contains(\"human post\") || body.contains(\"view-meta\")\n+ );\n+}\n+\n+#[tokio::test]\n+async fn test_thread_index_trailing_slash_redirects() {\n+ let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::builder()\n+ .redirect(reqwest::redirect::Policy::none())\n+ .build()\n+ .unwrap();\n+ let resp = client\n+ .get(format!(\"http://{addr}/t/\"))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert_eq!(resp.status(), reqwest::StatusCode::PERMANENT_REDIRECT);\n+ let loc = resp\n+ .headers()\n+ .get(reqwest::header::LOCATION)\n+ .and_then(|v| v.to_str().ok())\n+ .unwrap_or(\"\");\n+ assert_eq!(loc, \"/t\");\n+}\ndiff --git a/test/browser_draft_autosave.clj b/test/browser_draft_autosave.clj\nindex 10a2a0139ad4a291452fadab11a5a9fbda7408a1..ec76748035784fc85a0600edb5d53548cf5f26cb 100644\n--- a/test/browser_draft_autosave.clj\n+++ b/test/browser_draft_autosave.clj\n@@ -109,8 +109,8 @@\n (is (nil? cleared) \"draft removed from localStorage after successful post\"))\n \n ;; New thread compose: tag + body persist across reload when expanded\n- (page/navigate pg base-url)\n- (is (wait-for-text pg \"#new-thread-ui-slot\" \"+\" 15000) \"new thread slot on home\")\n+ (page/navigate pg (str base-url \"/t\"))\n+ (is (wait-for-text pg \"#new-thread-ui-slot\" \"+\" 15000) \"new thread slot on /t\")\n (locator/click (page/locator pg \"#new-thread-ui-slot button.form-toggle\"))\n (is (wait-for-text pg \"#new-thread-compose\" \"create thread\" 15000) \"new thread compose expanded\")\n (locator/fill (page/locator pg \"#new-thread-tag\") \"draft-tag-slug\")\ndiff --git a/test/browser_sse.clj b/test/browser_sse.clj\nindex 0f645080d4c04c5adc48c89651063b70d2e242d1..4f8dd2f887c9246ba60f80cf1010d7a225fd8048 100644\n--- a/test/browser_sse.clj\n+++ b/test/browser_sse.clj\n@@ -151,13 +151,13 @@\n (login-user! alice-pg base-url \"alice\")\n (login-user! bob-pg base-url \"bob\")\n \n- (page/navigate alice-pg base-url)\n+ (page/navigate alice-pg (str base-url \"/t\"))\n (page/wait-for-load-state alice-pg :load)\n (is (wait-for-text alice-pg \"#new-thread-ui-slot\" \"+\" 30000)\n- \"home has new-thread slot\")\n+ \"/t has new-thread slot\")\n (locator/click (page/locator alice-pg \"#new-thread-ui-slot button.form-toggle\"))\n (is (wait-for-text alice-pg \"#new-thread-compose\" \"create thread / post\" 30000)\n- \"compose expanded on home\")\n+ \"compose expanded on /t\")\n (locator/fill (page/locator alice-pg \"#new-thread-tag\") thread-tag)\n (locator/fill (page/locator alice-pg \"#new-thread-compose textarea\") \"seed public thread\")\n (locator/click (page/locator alice-pg \"#new-thread-form button[type='submit']\"))\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}