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