diff --git a/agents.md b/agents.md index 3f924c68dce0103da837c263804d2bb031ab9711..0a37b3e52da06b064ac5a2039006e4f5e6a5ef18 100644 --- a/agents.md +++ b/agents.md @@ -18,7 +18,7 @@ The web app is **not** a SPA with a JSON API for every interaction. Many mutatio - **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`). - 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. -- The home page (`GET /`) is the public **thread index**: private rooms when signed in, bump-ordered public threads, new-thread slot, `public_post_stats` header (no `delegate` → human, `delegate` set → AI; `system:…` and redacted/private omitted). Links to **`/~`** (garden). The `npx slugsocial` splash remains the static embedded `GUIDE.sorter` (no network). +- The home page (`GET /`) is the public **thread index**: private rooms when signed in, bump-ordered public threads, new-thread slot, `public_post_stats` header (no `delegate` → human, `delegate` set → AI; `system:…` and redacted/private omitted). Links to **`/~`** (garden) plus a `judge one pair` CTA at **`/vote`**. The `npx slugsocial` splash remains the static embedded `GUIDE.sorter` (no network). - The old thread-index path **`GET /t`** 308s to **`/`** (same for `/t/`). `/t/:tag` thread pages are unchanged. Live feed SSE prefixes for the public bump list are `/` and `/t/:tag`. Brand / home / forum-index links point at `/`. - 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). @@ -54,6 +54,8 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **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. +- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the least-judged tilde scope first (`pick_neediest_scope` in `server/src/html/garden/vote.rs`: lowest voted-pair density, ties prefer more members) with a 3-step intro, or an empty state when everything is judged. `/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. + - **`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`**. - **`ResolveExternal`:** Resolver buttons (GitHub, are.na) are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (per-domain resolver + import card JSON); **`mod.rs::resolve_external_children`** dispatches on URL host and **`try_render_resolver_item_body`** tries each card renderer before falling back to the usual **`
`** linkified view (called from **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, used by ontology item pages and the **`GET /vote/compare`** left/right columns). The resolver panel (**`external_resolver_controls`** in **`server/src/html/garden/external.rs`**) renders per-host ("GitHub resolver" / "Are.na resolver"); only GitHub items get a siblings button (are.na block URLs are not path-children of their channel).
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index fe09d437b3501795de43e5d434949be46d212bde..3fa3f055e3f0a64dde1c0318193ce80369a7f67f 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -237,7 +237,7 @@ Add --json to scoped commands for machine-readable output (RPC-shaped JSON where
 ~/contact {
 Web: https://slug.social — thread index (what's in circulation)
 Garden: https://slug.social/~ (rankings) ; e.g. https://slug.social/~/psalms
-Vote: https://slug.social/vote?pool=~/psalms (judge a pair)
+Vote: https://slug.social/vote (deals the pair that needs you) or /vote?pool=~/psalms
 GitHub: https://github.com/sortersocial/slug
 Issues/feedback: https://github.com/sortersocial/slug/issues
 }
diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs
index 6f67a3b50871fc5f7ebcc995c4bc8d48499764ff..802c6aefcf899677567f7addffb8e1b97cf7e436 100644
--- a/server/src/html/forum/feed.rs
+++ b/server/src/html/forum/feed.rs
@@ -370,6 +370,11 @@ pub async fn thread_index(
                 }
             }
             p class="muted" { "dark = time-ordered · light = vote-ranked" }
+            p class="muted home-judge-cta" {
+                "new here? "
+                a href="/vote" { "judge one pair" }
+                " — rankings come from comparisons."
+            }
             div id="new-thread-ui-slot" {
                 @if user.is_some() {
                     (fragment_new_thread_slot(&nav, true, false))
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index f63968d324d781b9da9ff62dad86356b26ef4415..2260efcac3a5efc6b971b586e449300ecb5c7a4d 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -88,11 +88,20 @@ pub(super) fn aspect_ranking_sections_markup(
     pin_ref: Option<&(String, ItemId)>,
     scope_content: &ContentState,
     next_for_pin: &str,
+    pool_item: &str,
 ) -> maud::Markup {
     html! {
         @for aspect in aspects {
             section class="ont-tab-panel ont-tab-panel-aspect" id=(format!("aspect-{}", aspect.slug)) {
-                h4 class="ont-aspect-heading" { ":" (aspect.slug) }
+                h4 class="ont-aspect-heading" {
+                    ":" (aspect.slug)
+                    " "
+                    a class="ont-vote-children-btn" href=(format!(
+                        "{}&aspect={}",
+                        vote_pool_href(nav, pool_item),
+                        urlencoding::encode(&aspect.slug)
+                    )) { "vote" }
+                }
                 @if let Some(prompt) = &aspect.prompt {
                     p class="muted ont-aspect-prompt" { (prompt) }
                 }
@@ -335,6 +344,7 @@ pub(super) async fn render_scope_view(
                 pin_ref.as_ref(),
                 scope_content,
                 &next_for_pin,
+                model.item.as_str(),
             ))
             @let cli = match &scope {
                 ScopeId::Public => format!("npx slugsocial public garden body {cli_path_arg}"),
diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs
index 49e184f1cac1ae90045a101fa3d9f2da65187600..226571b7dafe7c2a274f6b343cda8b55d94a604a 100644
--- a/server/src/html/garden/routes.rs
+++ b/server/src/html/garden/routes.rs
@@ -61,6 +61,11 @@ pub async fn garden_index(
 ) -> impl IntoResponse {
     let nav = ThreadNav::public();
     let child_depth = child_depth_from_uri(&uri);
+    // NOTE: no tilde/external split here on purpose. Root membership only
+    // ever holds tilde scopes (the DSL has no spelling for a URL `<: ~`
+    // claim, and URL items are never path-sugared), so the index is already
+    // tilde-only. Import volume drowns `tree`/`leaves`/global-rank instead —
+    // server-RPC surfaces, deliberately untouched here.
     let (child_rankings, aspect_rankings) = {
         let reduced = state.reduced.read().await;
         let content = reduced.public();
@@ -103,6 +108,7 @@ pub async fn garden_index(
                 pin_ref.as_ref(),
                 scope_content,
                 &next_for_pin,
+                "~",
             ),
         )
     };
diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs
index 782c1b0fa17030bdf45a5e152b4e9b9e3b2868fe..82dc8c8a2a5e83bc4b726e91bd8bd92ba8cd161c 100644
--- a/server/src/html/garden/tests.rs
+++ b/server/src/html/garden/tests.rs
@@ -10,8 +10,8 @@ use super::{
     render::aspect_ranking_sections_markup,
     vote::{
         canonical_edge_items, edge_vote_count_for_pair, edge_vote_entries_for_pair,
-        ratios_for_compare_page, sort_votes_for_compare_display, suggest_next_vote_pair,
-        vote_compare_item_card, vote_pool_href,
+        pick_neediest_scope, ratios_for_compare_page, sort_votes_for_compare_display,
+        suggest_next_vote_pair, vote_compare_item_card, vote_pool_href,
     },
 };
 use crate::{
@@ -340,6 +340,7 @@ fn item_page_renders_aspect_section_below_canonical() {
         None,
         content,
         "/~/topic",
+        "~/topic",
     )
     .into_string();
     assert!(
@@ -842,3 +843,41 @@ fn pair_weight_label_counts_sugar_like_explicit() {
     assert!(sugar_st.sugar);
     assert_eq!(pair_weight_label(&sugar_st), "containment 1 · border 0");
 }
+
+/// The `/vote` landing deals the least-judged scope first and skips scopes
+/// whose pairs are all voted.
+#[test]
+fn pick_neediest_scope_prefers_unjudged_scope() {
+    let mut reduced = ReducerState::default();
+    apply_ingest(
+        &mut reduced,
+        1,
+        "~/done/a { alpha }\n~/done/b { beta }\n{ judged }\n~/done/a 2:1 ~/done/b\n",
+    );
+    apply_ingest(
+        &mut reduced,
+        2,
+        "~/fresh/x { ex }\n~/fresh/y { why }\n~/fresh/z { zed }\n",
+    );
+    let content = content_for_garden_view(&reduced, &ScopeId::Public);
+    let (scope, voted, possible) =
+        pick_neediest_scope(content).expect("an unjudged scope");
+    assert!(
+        scope.as_str().ends_with("fresh"),
+        "should deal ~/fresh, got {}",
+        scope.as_str()
+    );
+    assert_eq!((voted, possible), (0, 3));
+}
+
+#[test]
+fn pick_neediest_scope_returns_none_when_all_judged() {
+    let mut reduced = ReducerState::default();
+    apply_ingest(
+        &mut reduced,
+        1,
+        "~/done/a { alpha }\n~/done/b { beta }\n{ judged }\n~/done/a 2:1 ~/done/b\n",
+    );
+    let content = content_for_garden_view(&reduced, &ScopeId::Public);
+    assert!(pick_neediest_scope(content).is_none());
+}
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index ad6204f021d4e397494a658f185219a2b920d8b5..4fdb244b2e5511a8ff86105a55f5b563146717e1 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -30,7 +30,7 @@ use super::{
         content_for_garden_view, room_not_found_page, room_scope_has_garden_content,
         user_can_view_room,
     },
-    item::{item_display_path, login_href_with_next},
+    item::{item_display_path, item_href, login_href_with_next},
 };
 
 /// Unique element ids for a compare panel. Empty suffix is the `/vote` page.
@@ -562,6 +562,63 @@ pub struct VoteCompareQuery {
     pub thread: Option,
     #[serde(default)]
     pub pool: Option,
+    /// Aspect sub-question to vote under (`:slug` group instead of canonical).
+    #[serde(default)]
+    pub aspect: Option,
+}
+
+/// Neediest scope for the `/vote` landing: lowest voted-pair density among
+/// tilde scopes with ≥2 comparable members. Returns scope + voted + possible.
+/// Fully-judged scopes are skipped; `None` means everything is judged.
+pub(super) fn pick_neediest_scope(content: &ContentState) -> Option<(ItemId, usize, usize)> {
+    let group = &content.ranking_group;
+    // (density_num, density_den, scope): lowest density wins; ties prefer more
+    // members, then lex-smallest scope (iteration order is lex-sorted, and
+    // only strictly-better candidates replace the incumbent).
+    let mut best: Option<(usize, usize, usize, ItemId)> = None;
+    let mut scopes: Vec<&ItemId> = content
+        .members_by_scope
+        .keys()
+        .filter(|id| matches!(id.tilde_tail(), Some(t) if !t.is_empty() && !t.contains('/')))
+        .collect();
+    scopes.sort_by(|a, b| a.as_str().cmp(b.as_str()));
+    for scope in scopes {
+        let members = comparable_items(content, content.members_of(scope));
+        if members.len() < 2 {
+            continue;
+        }
+        let possible = members.len() * (members.len() - 1) / 2;
+        let idx: Vec = members
+            .iter()
+            .filter_map(|m| group.item_to_idx.get(m).copied())
+            .collect();
+        let mut voted = 0usize;
+        for (i, &a) in idx.iter().enumerate() {
+            for &b in &idx[i + 1..] {
+                let key = if a < b { (a, b) } else { (b, a) };
+                if group.voted_pairs.contains(&key) {
+                    voted += 1;
+                }
+            }
+        }
+        if voted >= possible {
+            continue;
+        }
+        // Compare voted/possible without floats: a/b < c/d ⟺ a·d < c·b.
+        // strictly-less keeps the lex-first scope on exact ties; larger member
+        // count wins on equal density.
+        let take = match &best {
+            None => true,
+            Some((bv, bp, bn, _)) => {
+                (voted * bp).cmp(&(bv * possible)) == std::cmp::Ordering::Less
+                    || (voted * bp == bv * possible && members.len() > *bn)
+            }
+        };
+        if take {
+            best = Some((voted, possible, members.len(), (*scope).clone()));
+        }
+    }
+    best.map(|(voted, possible, _, scope)| (scope, voted, possible))
 }
 
 /// Public pairwise vote UI — `/vote?left=&right=&thread=`.
@@ -637,7 +694,7 @@ pub(super) async fn vote_compare_inner(
         }
         (None, None) => {
             let Some(pool) = pool_id.as_ref() else {
-                return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response();
+                return vote_landing(state, nav, headers, jar, uri).await;
             };
             let reduced = state.reduced.read().await;
             let content = content_for_garden_view(&reduced, &nav.scope());
@@ -669,6 +726,49 @@ pub(super) async fn vote_compare_inner(
         }
     };
 
+    let aspect_slug = q
+        .aspect
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+        .map(str::to_string);
+    if let Some(slug) = &aspect_slug {
+        if !crate::dsl::is_valid_aspect_slug(slug) {
+            return (StatusCode::BAD_REQUEST, "bad aspect slug").into_response();
+        }
+    }
+
+    render_compare_page(
+        state,
+        nav,
+        headers,
+        jar,
+        uri,
+        left,
+        right,
+        pool_id,
+        aspect_slug,
+        q.thread.clone(),
+        None,
+    )
+    .await
+}
+
+/// Render one judged pair: intro header (landing only) + compare panel.
+#[allow(clippy::too_many_arguments)]
+async fn render_compare_page(
+    state: AppState,
+    nav: ThreadNav,
+    headers: HeaderMap,
+    jar: CookieJar,
+    uri: Uri,
+    left: ItemId,
+    right: ItemId,
+    pool_id: Option,
+    aspect_slug: Option,
+    query_thread: Option,
+    intro: Option,
+) -> axum::response::Response {
     let reduced = state.reduced.read().await;
     let content = content_for_garden_view(&reduced, &nav.scope());
     let viewer = optional_principal(&headers, &jar, &reduced);
@@ -683,10 +783,9 @@ pub(super) async fn vote_compare_inner(
     // Guests see the same compose UI; submitting VoteComparePost redirects to
     // `/login?next=` so OAuth returns them to the shared matchup.
     let show_vote_form = can_post || !logged_in;
-    let auto_thread = q
-        .thread
-        .as_ref()
-        .map(|t| canonicalize_tag(t))
+    let auto_thread = query_thread
+        .as_deref()
+        .map(canonicalize_tag)
         .filter(|t| !t.is_empty())
         .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));
     let mut thread_tags = vote_thread_tags_for_pair(content, &left, &right);
@@ -713,7 +812,7 @@ pub(super) async fn vote_compare_inner(
     let view_class =
         "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen";
 
-    let body = vote_compare_panel_markup(VoteComparePanel {
+    let panel = vote_compare_panel_markup(VoteComparePanel {
         nav: &nav,
         left: &left,
         right: &right,
@@ -726,12 +825,18 @@ pub(super) async fn vote_compare_inner(
         edge_history,
         next_pair: next_pair.as_ref(),
         next_path: &next_path,
-        aspect_slug: None,
+        aspect_slug: aspect_slug.as_deref(),
         logged_in,
         show_vote_form,
         include_heading: true,
         ids: &VoteCompareDomIds::page(),
     });
+    let body = html! {
+        @if let Some(intro) = intro {
+            (intro)
+        }
+        (panel)
+    };
 
     let url_key = canonical_view_url(&uri);
     let view_count = state.views.get_views(&url_key);
@@ -746,3 +851,100 @@ pub(super) async fn vote_compare_inner(
     );
     Html(page.into_string()).into_response()
 }
+
+/// `GET /vote` with no pair: deal the neediest scope first — the judgment
+/// entry point. First run is three steps; every later visit is one pair.
+async fn vote_landing(
+    state: AppState,
+    nav: ThreadNav,
+    headers: HeaderMap,
+    jar: CookieJar,
+    uri: Uri,
+) -> axum::response::Response {
+    let (scope, voted, possible) = {
+        let reduced = state.reduced.read().await;
+        let content = content_for_garden_view(&reduced, &nav.scope());
+        match pick_neediest_scope(content) {
+            Some(pick) => pick,
+            None => {
+                drop(reduced);
+                return landing_empty_page(&state, &jar, &uri).await;
+            }
+        }
+    };
+    let pair = {
+        let reduced = state.reduced.read().await;
+        let content = content_for_garden_view(&reduced, &nav.scope());
+        let members = comparable_items(content, content.members_of(&scope));
+        suggest_next_pair_in_pool(&content.ranking_group, &members, None)
+    };
+    let Some((left, right)) = pair else {
+        return landing_empty_page(&state, &jar, &uri).await;
+    };
+    let intro = html! {
+        header class="vote-landing" {
+            h1 class="vote-landing-title" { "judge one pair" }
+            p class="vote-landing-need" {
+                (item_display_path(scope.as_str()))
+                " has "
+                (format!("{voted} of {possible}"))
+                " comparisons so far — your vote counts here more than anywhere else."
+            }
+            ol class="vote-landing-steps" {
+                li { "compare the two items below" }
+                li { "drag the slider, then write why (required)" }
+                li {
+                    "post — your vote ranks them in "
+                    a href=(item_href(scope.as_str(), &nav)) {
+                        (item_display_path(scope.as_str()))
+                    }
+                }
+            }
+        }
+    };
+    render_compare_page(
+        state,
+        nav,
+        headers,
+        jar,
+        uri,
+        left,
+        right,
+        Some(scope),
+        None,
+        None,
+        Some(intro),
+    )
+    .await
+}
+
+/// Nothing left to judge: every scope with two comparable members is fully compared.
+async fn landing_empty_page(
+    state: &AppState,
+    jar: &CookieJar,
+    uri: &Uri,
+) -> axum::response::Response {
+    let url_key = canonical_view_url(uri);
+    let view_count = state.views.get_views(&url_key);
+    let page = layout_full_bleed_chromeless(
+        "vote",
+        "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen",
+        html! {
+            header class="vote-landing" {
+                h1 class="vote-landing-title" { "judge one pair" }
+                p class="vote-landing-need" {
+                    "everything with two comparable members has been fully compared. "
+                    "Browse the "
+                    a href="/~" { "garden" }
+                    ", or start a "
+                    a href="/" { "thread" }
+                    " to open a new question."
+                }
+            }
+        },
+        Some(view_count),
+        theme_from_jar(jar),
+        &theme_next_from_uri(uri),
+    );
+    Html(page.into_string()).into_response()
+}
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 969710b8a2241b9b5e8c8c7abedd0bc19c876b30..f2e54588842802e848427a0597a5f372de3d2e52 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -2315,3 +2315,30 @@ body.view-ontology.view-ontology-light ul.ont-group-list li:target {
   outline: 2px solid var(--link);
   outline-offset: 1px;
 }
+
+/* ----------------------------------------------------------------
+   VOTE LANDING — judgment entry point (`GET /vote` with no pair).
+   One pair that needs you, plus the three-step first run.
+   ---------------------------------------------------------------- */
+.vote-landing {
+  margin: 24px auto;
+  max-width: 560px;
+  padding: 0 16px;
+}
+.vote-landing-title {
+  font-size: 22px;
+  letter-spacing: normal;
+  margin: 0 0 8px;
+  text-transform: none;
+}
+.vote-landing-need {
+  color: var(--prose);
+  margin: 0 0 12px;
+}
+ol.vote-landing-steps {
+  margin: 0 0 16px;
+  padding-left: 1.4em;
+}
+ol.vote-landing-steps li {
+  margin: 4px 0;
+}
diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
index 83051ea47dd6babe2b69b869eb9b8144f26524fc..5748cdb5c588bb389ca9ff413377ce9b6b7cb9ab 100644
--- a/server/static/theme_retro.css
+++ b/server/static/theme_retro.css
@@ -460,6 +460,19 @@ body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich a
 details.question-row > summary { cursor: pointer; }
 .question-row-links { font-size: 13px; }
 
+/* Vote landing (`GET /vote` with no pair): judgment entry point. Lean retro
+   keeps browser heading sizes; only the column and list need rules. */
+body.view-ontology .vote-landing {
+  margin: 1.5rem auto;
+  max-width: 560px;
+}
+body.view-ontology ol.vote-landing-steps {
+  padding-left: 1.4em;
+}
+body.view-ontology ol.vote-landing-steps li {
+  margin: 0.3rem 0;
+}
+
 /* ================================================================
    GARDEN HIERARCHY — parents ↑ / self / children ↓ (paper context).
    Lean retro version of the same system in the other themes:
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 42b3662f5fdcd22818e107ad131e89567cf157c2..867f8fec9c6240e231b539828a13e659a85b2438 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -1290,6 +1290,33 @@ body.view-ontology #vote-compare-preview {
   min-height: 2.5rem;
 }
 
+/* Vote landing (`GET /vote` with no pair): judgment entry point. */
+body.view-ontology .vote-landing {
+  margin: 2rem auto;
+  max-width: 38rem;
+  padding: 0 1.25rem;
+}
+body.view-ontology .vote-landing-title {
+  color: #1a1814;
+  font-family: var(--font-prose);
+  font-size: 1.6rem;
+  font-weight: 600;
+  letter-spacing: normal;
+  margin: 0 0 0.5rem;
+  text-transform: none;
+}
+body.view-ontology .vote-landing-need {
+  color: #3d3a34;
+  margin: 0 0 1rem;
+}
+body.view-ontology ol.vote-landing-steps {
+  margin: 0 0 1.25rem;
+  padding-left: 1.4em;
+}
+body.view-ontology ol.vote-landing-steps li {
+  margin: 0.3rem 0;
+}
+
 .home-intro { margin: 1rem 0; }
 details.question-row {
   border: 1px solid #c4b89a;
diff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs
index f48c7ae7cebcadc5511474df7bdb9821ea9514be..2c7f52bbb36b69135268d600f404f4a34e1b42c2 100644
--- a/server/tests/integration_html.rs
+++ b/server/tests/integration_html.rs
@@ -399,6 +399,122 @@ async fn test_room_question_pages_are_gone() {
     }
 }
 
+fn landing_seed_ingest() -> Event {
+    Event::Ingest(Ingest {
+        ts: 21,
+        id: "ing-duel".into(),
+        raw: "~/duel/a { Alpha }\n~/duel/b { Beta }\n".to_string(),
+        principal: "testuser".to_string(),
+        delegate: None,
+        room_id: "public".into(),
+        thread_tag: "duel".into(),
+    })
+}
+
+#[tokio::test]
+async fn test_vote_landing_serves_neediest_pair() {
+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+    {
+        let mut w = state.reduced.write().await;
+        w.apply_event(landing_seed_ingest());
+    }
+    let client = reqwest::Client::new();
+    let resp = client
+        .get(format!("http://{addr}/vote"))
+        .send()
+        .await
+        .unwrap();
+    assert!(resp.status().is_success(), "{}", resp.status());
+    let body = resp.text().await.unwrap();
+    assert!(body.contains("judge one pair"), "landing intro missing");
+    assert!(body.contains("0 of 1"), "need count missing");
+    assert!(body.contains("drag the slider"), "steps missing");
+    assert!(body.contains("href=\"/~/duel\""), "scope link missing");
+    assert!(body.contains("vote-compare-pair"));
+
+    // Guests get the login CTA; the seeded compose form needs a session.
+    let authed = client
+        .get(format!("http://{addr}/vote"))
+        .header("Authorization", format!("Bearer {}", test_bearer()))
+        .send()
+        .await
+        .unwrap();
+    assert!(authed.status().is_success(), "{}", authed.status());
+    let body = authed.text().await.unwrap();
+    assert!(body.contains("value=\"duel\""), "thread_tag should seed to pool leaf");
+}
+
+#[tokio::test]
+async fn test_vote_aspect_query_param() {
+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+    {
+        let mut w = state.reduced.write().await;
+        w.apply_event(Event::Ingest(Ingest {
+            ts: 22,
+            id: "ing-tri".into(),
+            raw: "~/tri/a { alpha }\n~/tri/b { beta }\n~/tri/c { gamma }\n\
+                  { ab }\n~/tri/a 2:1 ~/tri/b\n\
+                  :beauty {more beautiful}\n{ pretty }\n~/tri/a 2:1 ~/tri/b\n"
+                .to_string(),
+            principal: "testuser".to_string(),
+            delegate: None,
+            room_id: "public".into(),
+            thread_tag: "tri".into(),
+        }));
+    }
+    let client = reqwest::Client::new();
+    let resp = client
+        .get(format!("http://{addr}/vote?pool=~/tri&aspect=beauty"))
+        .header("Authorization", format!("Bearer {}", test_bearer()))
+        .send()
+        .await
+        .unwrap();
+    assert!(resp.status().is_success(), "{}", resp.status());
+    let body = resp.text().await.unwrap();
+    assert!(body.contains("name=\"aspect\""), "aspect input missing");
+    assert!(body.contains("value=\"beauty\""), "aspect value missing");
+
+    let bad = client
+        .get(format!("http://{addr}/vote?left=~/tri/a&right=~/tri/b&aspect=NOPE"))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(bad.status(), reqwest::StatusCode::BAD_REQUEST);
+}
+
+#[tokio::test]
+async fn test_garden_index_lists_tilde_scopes() {
+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+    {
+        let mut w = state.reduced.write().await;
+        w.apply_event(Event::Ingest(Ingest {
+            ts: 23,
+            id: "ing-mix".into(),
+            raw: "~mix { Mixed scope }\n~/mix/a { alpha }\n\
+                  https://example.com/thing { An imported thing }\n\
+                  { import }\nhttps://example.com/thing <: ~mix\n"
+                .to_string(),
+            principal: "testuser".to_string(),
+            delegate: None,
+            room_id: "public".into(),
+            thread_tag: "mix".into(),
+        }));
+    }
+    let client = reqwest::Client::new();
+    let body = client
+        .get(format!("http://{addr}/~"))
+        .send()
+        .await
+        .unwrap()
+        .text()
+        .await
+        .unwrap();
+    assert!(body.contains("~/mix"), "tilde scope missing");
+    // Root membership is tilde-only by construction (the DSL has no spelling
+    // for a URL `<: ~` claim), so the index never mixes in imports.
+    assert!(!body.contains("example.com/thing"));
+}
+
 #[tokio::test]
 async fn test_home_is_thread_index() {
     let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
@@ -425,6 +541,8 @@ async fn test_home_is_thread_index() {
     );
     assert!(guest_body.contains("forum-home") || guest_body.contains("#forum-home"));
     assert!(guest_body.contains("log in to post"));
+    assert!(guest_body.contains("judge one pair"));
+    assert!(guest_body.contains("href=\"/vote\""));
     assert!(!guest_body.contains("data-testid=\"question-index\""));
 
     let authed = client