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: [0366806e] Color rank rows by vote mass within each group, not list position. Min–max normalization keeps similar scores visually close while still using the full gradient as groups grow and absolute mass dilutes. Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index a58cbbee3490a08a625cb06df06848c59a615d65..4eff2e19ed4d303ff8e80c1eabd8a15b4990e643 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -264,12 +264,19 @@ pub fn scope_theme_style(parent: &ItemId) -> String { ) } -fn rank_row_style(parent: &ItemId, ordinal: usize, total: usize) -> String { - let t = if total <= 1 { - 0.0 - } else { - ordinal as f64 / (total - 1) as f64 - }; +/// Map vote mass to gradient position using the group's score range, not raw mass or +/// list position. Vote mass sums to 1 across the component, so absolute values dilute +/// as N grows; min–max within the visible list preserves similar scores → similar colors. +fn score_gradient_t(score: f64, min_score: f64, max_score: f64) -> f64 { + let spread = max_score - min_score; + if spread < 1e-9 { + return 0.5; + } + ((max_score - score) / spread).clamp(0.0, 1.0) +} + +fn rank_row_style(parent: &ItemId, score: f64, min_score: f64, max_score: f64) -> String { + let t = score_gradient_t(score, min_score, max_score); let base_hue = scope_base_hue(parent); let hue = (base_hue + 118.0 * t) % 360.0; let lightness = 0.74 - 0.34 * t; @@ -295,14 +302,15 @@ fn rank_list( highlighted: &HashSet, tree: &GlobalTree, ) -> Markup { - let group_len = items.len(); + let min_score = items.iter().map(|r| r.score).fold(f64::INFINITY, f64::min); + let max_score = items.iter().map(|r| r.score).fold(f64::NEG_INFINITY, f64::max); html! { @if !items.is_empty() { h3 class="rank-heading muted small" { (label) } ol class="rank-list" { @for (i, r) in items.iter().enumerate() { @let href = item_href(&r.item); - @let style = rank_row_style(parent, i, group_len); + @let style = rank_row_style(parent, r.score, min_score, max_score); @let class = rank_row_class(&r.item, highlighted); li class=(class) data-rank-item=(r.item.as_str()) @@ -517,19 +525,37 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons #[cfg(test)] mod tests { - use super::{rank_row_style, SORTER_UI_JS}; + use super::{rank_row_style, score_gradient_t, SORTER_UI_JS}; use crate::path_types::ItemId; #[test] - fn rank_row_style_gradients_per_group_not_globally() { + fn score_gradient_t_uses_group_range_not_absolute_mass() { + assert!((score_gradient_t(0.12, 0.08, 0.12) - 0.0).abs() < 1e-9); + assert!((score_gradient_t(0.08, 0.08, 0.12) - 1.0).abs() < 1e-9); + // Raw 12% mass would map near the dark end globally; within this group it's the top. + assert!(score_gradient_t(0.12, 0.08, 0.12) < score_gradient_t(0.12, 0.0, 1.0)); + } + + #[test] + fn score_gradient_t_similar_scores_similar_t() { + let a = score_gradient_t(0.41, 0.20, 0.60); + let b = score_gradient_t(0.40, 0.20, 0.60); + assert!((a - b).abs() < 0.05); + assert!((a - score_gradient_t(0.60, 0.20, 0.60)).abs() > 0.3); + } + + #[test] + fn score_gradient_t_tied_scores_neutral() { + assert!((score_gradient_t(0.25, 0.25, 0.25) - 0.5).abs() < 1e-9); + } + + #[test] + fn rank_row_style_same_inputs_same_color() { let parent = ItemId::opaque("test-scope"); - let first_in_four = rank_row_style(&parent, 0, 4); - let last_in_four = rank_row_style(&parent, 3, 4); - let first_in_two = rank_row_style(&parent, 0, 2); - let last_in_two = rank_row_style(&parent, 1, 2); - assert_eq!(first_in_four, first_in_two); - assert_eq!(last_in_four, last_in_two); - assert_ne!(first_in_four, last_in_four); + assert_eq!( + rank_row_style(&parent, 0.33, 0.20, 0.60), + rank_row_style(&parent, 0.33, 0.20, 0.60), + ); } #[test] Side B — contributor: tommy-mor Side B — commit message: [2fe70b0e] themes Side B — unified diff (full patch): diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index 559db65de14c6157690ffbf18eca0cf65b0a5202..b3631b06153d52f88348fef927e7a024b7b85ad6 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -1,7 +1,7 @@ use axum::{ body::Body, extract::{Path, Query, State}, - http::{header, HeaderMap, HeaderValue, StatusCode}, + http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, response::{IntoResponse, Redirect, Response}, Form, Json, }; @@ -17,7 +17,7 @@ use crate::{ events::{Event, GrantAdded, TokenIssued, UserRegistered}, html::{ auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, - choose_username_page, JsBuilder, + choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder, }, identity::{parse_agent, parse_username}, reducer::ReducerState, @@ -48,15 +48,17 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response { .into_response() } -fn js_signed_in_fragment(bearer: &str) -> Response { +fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response { let mut response = JsBuilder::new() .id("choose-username-form") .morph_inner(auth_signed_in_fragment()) .redirect("/auth/complete") .into_response(); - response - .headers_mut() - .insert(header::SET_COOKIE, session_cookie_header_value(bearer)); + let headers = response.headers_mut(); + headers.append(header::SET_COOKIE, session_cookie_header_value(bearer)); + if let Some(theme) = theme_cookie_header_from_jar(jar) { + headers.append(header::SET_COOKIE, theme); + } response } @@ -69,13 +71,18 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce verify_token(reduced, c.value()).ok() } -fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str) -> Response { - Response::builder() +fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response { + let mut res = Response::builder() .status(StatusCode::TEMPORARY_REDIRECT) .header(header::LOCATION, format!("{public_url}{path_and_query}")) - .header(header::SET_COOKIE, session_cookie_header_value(bearer)) .body(Body::empty()) - .unwrap() + .unwrap(); + let headers = res.headers_mut(); + headers.append(header::SET_COOKIE, session_cookie_header_value(bearer)); + if let Some(theme) = theme_cookie_header_from_jar(jar) { + headers.append(header::SET_COOKIE, theme); + } + res } async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> { @@ -286,7 +293,11 @@ pub struct AuthCallbackQuery { pub state: String, } -pub async fn get_auth_callback(Query(q): Query, State(state): State) -> impl IntoResponse { +pub async fn get_auth_callback( + Query(q): Query, + State(state): State, + jar: CookieJar, +) -> impl IntoResponse { let sessions = pending_sessions(&state); { let sessions_read = sessions.read().await; @@ -365,7 +376,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state): } let cookie_bearer = bearer.clone(); s.complete = Some((username, bearer)); - return redirect_with_session_cookie(&public_url, "/", &cookie_bearer).into_response(); + return redirect_with_session_cookie(&public_url, "/", &cookie_bearer, &jar).into_response(); } } @@ -378,14 +389,20 @@ pub struct ChooseUsernameQuery { pub error: Option, } -pub async fn get_choose_username(Query(q): Query, State(state): State) -> impl IntoResponse { +pub async fn get_choose_username( + Query(q): Query, + State(state): State, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { let sessions = pending_sessions(&state); let sessions_read = sessions.read().await; if !sessions_read.contains_key(&q.session) { return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); } drop(sessions_read); - choose_username_page(&q.session, q.error.as_deref()).into_response() + let next = theme_next_from_uri(&uri); + choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response() } #[derive(Debug, Deserialize)] @@ -396,6 +413,7 @@ pub struct ChooseUsernameForm { pub async fn post_choose_username( State(state): State, + jar: CookieJar, Form(form): Form, ) -> impl IntoResponse { let canon_user = match parse_username(&form.username) { @@ -477,7 +495,7 @@ pub async fn post_choose_username( s.complete = Some((canon_user.clone(), bearer.clone())); } - js_signed_in_fragment(&bearer).into_response() + js_signed_in_fragment(&bearer, &jar).into_response() } /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success. @@ -569,8 +587,9 @@ pub async fn get_pending_session( .into_response() } -pub async fn get_auth_complete() -> impl IntoResponse { - auth_complete_page() +pub async fn get_auth_complete(jar: CookieJar, uri: Uri) -> impl IntoResponse { + let next = theme_next_from_uri(&uri); + auth_complete_page(theme_from_jar(&jar), &next).into_response() } pub async fn get_whoami(State(state): State, headers: HeaderMap) -> impl IntoResponse { diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs index 81e2a55fa3abb8609b4099f91a989480336e11eb..9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee 100644 --- a/server/src/api/helpers.rs +++ b/server/src/api/helpers.rs @@ -39,6 +39,55 @@ pub fn item_path_for_api(item: &str) -> String { } } +/// Same as [`item_path_for_api`], but for private rooms ontology items are prefixed with +/// `/r/{short}/{slug}` so the URL matches the web app (`/r/…/~/…` routes). +pub fn item_path_for_api_in_room(item: &str, room_wire: &str) -> String { + let room = room_wire.trim(); + if room.is_empty() || room == "public" { + return item_path_for_api(item); + } + let Some((short, slug)) = room.split_once('/') else { + return item_path_for_api(item); + }; + if short.is_empty() || slug.is_empty() { + return item_path_for_api(item); + } + let Some(c) = CanonicalItemUrl::parse(item) else { + return item_path_for_api(item); + }; + let root = CanonicalItemUrl::ontology_root(); + let item_norm = c.as_str().trim_end_matches('/'); + let root_norm = root.as_str().trim_end_matches('/'); + if let Some(tail) = c.tilde_tail() { + return if tail.is_empty() { + format!("https://slug.social/r/{short}/{slug}/~") + } else { + format!("https://slug.social/r/{short}/{slug}/~/{}", tail) + }; + } + if item_norm == root_norm { + return format!("https://slug.social/r/{short}/{slug}/~"); + } + item_path_for_api(item) +} + +/// Absolute thread URL for forum JSON (`/t/…` vs `/r/…/t/…`). +pub fn forum_thread_web_url(room_wire: &str, thread_tag: &str) -> String { + let room = room_wire.trim(); + let tag = thread_tag.trim().trim_start_matches('#'); + if room.is_empty() || room == "public" { + format!("https://slug.social/t/{tag}") + } else if let Some((short, slug)) = room.split_once('/') { + if short.is_empty() || slug.is_empty() { + format!("https://slug.social/t/{tag}") + } else { + format!("https://slug.social/r/{short}/{slug}/t/{tag}") + } + } else { + format!("https://slug.social/t/{tag}") + } +} + /// Resolve an item path as a first-class canonical path. pub fn resolve_item(item: &str) -> Result { let canonical = canonicalize_item(item); @@ -188,3 +237,52 @@ pub fn vote_touches_path(a: &str, b: &str, parent_canon: &str) -> bool { let under = |item: &str| item == parent_canon || item.starts_with(&format!("{}/", parent_canon)); under(a) || under(b) } + +#[cfg(test)] +mod wire_url_tests { + use super::{forum_thread_web_url, item_path_for_api_in_room}; + + #[test] + fn public_room_unchanged() { + let u = "https://slug.social/~/a/b"; + assert_eq!(item_path_for_api_in_room(u, "public"), u); + } + + #[test] + fn private_room_prefixes_ontology() { + assert_eq!( + item_path_for_api_in_room("https://slug.social/~/topic/x", "9ab12cd/my-room"), + "https://slug.social/r/9ab12cd/my-room/~/topic/x" + ); + } + + #[test] + fn private_room_ontology_root() { + assert_eq!( + item_path_for_api_in_room("https://slug.social/~", "9ab12cd/my-room"), + "https://slug.social/r/9ab12cd/my-room/~" + ); + assert_eq!( + item_path_for_api_in_room("https://slug.social/~/", "9ab12cd/my-room"), + "https://slug.social/r/9ab12cd/my-room/~" + ); + } + + #[test] + fn external_url_untouched_in_private_room() { + let u = "https://example.com/z"; + assert_eq!(item_path_for_api_in_room(u, "9ab12cd/my-room"), u); + } + + #[test] + fn forum_web_public_vs_room() { + assert_eq!( + forum_thread_web_url("public", "debate"), + "https://slug.social/t/debate" + ); + assert_eq!( + forum_thread_web_url("9ab12cd/my-room", "#debate"), + "https://slug.social/r/9ab12cd/my-room/t/debate" + ); + } +} diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 31f5fcfb4eaf4df0a9cbac532dd3dedfe3611810..5b91f5836625eedbb1cd9423168046e3fb576c17 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -27,8 +27,9 @@ use crate::{ use super::auth::verify_bearer_principal; use super::helpers::{ - compute_connectivity_stats, is_pair_voted, item_path_for_api, now_ms, paginate_rankings, - parse_parent_specs, pick_random_distinct, resolve_item, vote_touches_path, + compute_connectivity_stats, forum_thread_web_url, is_pair_voted, item_path_for_api, + item_path_for_api_in_room, now_ms, paginate_rankings, parse_parent_specs, pick_random_distinct, + resolve_item, vote_touches_path, }; use super::validate::{normalize_room_and_thread, validate_ingest_document}; @@ -148,6 +149,7 @@ fn compute_scope_rank_changes( parent: &str, before: &crate::scope_rank::ChildrenRankings, after: &crate::scope_rank::ChildrenRankings, + room_wire: &str, ) -> Option { fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> { let mut map = HashMap::new(); @@ -182,7 +184,7 @@ fn compute_scope_rank_changes( }; if changed { changes.push(RankChange { - item: item_path_for_api(&item), + item: item_path_for_api_in_room(&item, room_wire), before: b, after: a, }); @@ -204,7 +206,7 @@ fn compute_scope_rank_changes( parent: if parent.is_empty() { "/".to_string() } else { - item_path_for_api(parent) + item_path_for_api_in_room(parent, room_wire) }, changes, }) @@ -256,6 +258,7 @@ fn build_rank_response_for_content( offset: usize, limit: Option, want_percent: bool, + room_wire: &str, ) -> Result { let parent_owned = parent.map(|s| s.to_string()); let specs = parse_parent_specs(parent_owned.as_ref()); @@ -299,7 +302,7 @@ fn build_rank_response_for_content( .ranked .into_iter() .map(|r| RankRow { - item: item_path_for_api(r.item.as_str()), + item: item_path_for_api_in_room(r.item.as_str(), room_wire), percent: if want_percent { Some((r.score / max_score) * 100.0) } else { @@ -315,7 +318,7 @@ fn build_rank_response_for_content( let prefixed_unranked: Vec = rankings .unranked_items .into_iter() - .map(|s| item_path_for_api(s.as_str())) + .map(|s| item_path_for_api_in_room(s.as_str(), room_wire)) .collect(); let (components, unranked_items) = if offset > 0 || limit.is_some() { @@ -522,7 +525,7 @@ async fn rpc_post( .filter_map(|p| { let before = pre_rankings.get(p)?; let after = crate::scope_rank::build_children_rankings(content, p); - compute_scope_rank_changes(p.as_str(), before, &after) + compute_scope_rank_changes(p.as_str(), before, &after, &room_key) }) .collect(); if v.is_empty() { None } else { Some(v) } @@ -530,14 +533,28 @@ async fn rpc_post( None }; + let (pair_hint, rank_hint, web_url) = if room_key == "public" { + ( + "npx slugsocial public garden pair".to_string(), + "npx slugsocial public garden rank".to_string(), + forum_thread_web_url("public", &thread_id), + ) + } else { + ( + format!("npx slugsocial private {room_key} garden pair"), + format!("npx slugsocial private {room_key} garden rank"), + forum_thread_web_url(&room_key, &thread_id), + ) + }; + Ok(RpcResult::PostOk { events_appended, ranking_changes, threads: vec![format!("#{}", thread_id)], next: NextMoves { - pair: "npx slugsocial public garden pair".to_string(), - rank: "npx slugsocial public garden rank".to_string(), - web: format!("https://slug.social/t/{}", thread_id), + pair: pair_hint, + rank: rank_hint, + web: web_url, }, }) } @@ -609,7 +626,7 @@ async fn rpc_check( raw: v.raw_text.clone(), principal, delegate, - room_id: room_key, + room_id: room_key.clone(), thread_tag: thread_id.clone(), }); @@ -647,7 +664,7 @@ async fn rpc_check( .ranked .into_iter() .map(|r| RankRow { - item: item_path_for_api(r.item.as_str()), + item: item_path_for_api_in_room(r.item.as_str(), &room_key), score: r.score, percent: None, }) @@ -655,25 +672,35 @@ async fn rpc_check( }) .collect(); CheckScopeRanking { - parent: item_path_for_api(parent.as_str()), + parent: item_path_for_api_in_room(parent.as_str(), &room_key), components, unranked_items: scoped .unranked_items .into_iter() - .map(|it| item_path_for_api(it.as_str())) + .map(|it| item_path_for_api_in_room(it.as_str(), &room_key)) .collect(), } }) .collect(); + let check_next = if room_key == "public" { + vec![ + "npx slugsocial public forum post --delegate ".to_string(), + "npx slugsocial public forum list".to_string(), + forum_thread_web_url("public", &thread_id), + ] + } else { + vec![ + format!("npx slugsocial private {room_key} forum post --delegate "), + format!("npx slugsocial private {room_key} forum list"), + forum_thread_web_url(&room_key, &thread_id), + ] + }; + Ok(RpcResult::CheckOk { rankings, threads: vec![format!("#{}", thread_id)], - next: vec![ - "npx slugsocial public forum post --delegate ".to_string(), - "npx slugsocial public forum list".to_string(), - format!("https://slug.social/t/{}", thread_id), - ], + next: check_next, }) } @@ -690,7 +717,7 @@ fn rpc_list_forum_threads(reduced: &ReducerState, room: &str) -> ThreadsResponse .map(|((_, tag), ts)| ThreadSummary { thread: format!("#{tag}"), last_activity_ts: ts.last_activity_ts, - web: format!("https://slug.social/t/{}", tag), + web: forum_thread_web_url(room, tag), }) .collect(); out.sort_by(|a, b| b.last_activity_ts.cmp(&a.last_activity_ts)); @@ -1031,8 +1058,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re .collect(); let cs = compute_connectivity_stats(&content.ranking_group, &pool); Ok(RpcResult::Pair(PairResponse { - left: item_path_for_api(&left), - right: item_path_for_api(&right), + left: item_path_for_api_in_room(&left, &room), + right: item_path_for_api_in_room(&right, &room), left_body: lb, right_body: rb, threads: th, @@ -1088,6 +1115,7 @@ pub async fn handle_rpc_batch( offset.unwrap_or(0), limit, percent.unwrap_or(false), + &room, ) { Ok(r) => line_ok(RpcResult::GardenRank(r)), Err((e, h)) => line_err(e, h), @@ -1109,7 +1137,7 @@ pub async fn handle_rpc_batch( if !content.items.contains(&item) { line_err( "item not found", - Some(format!("{} does not exist", item_path_for_api(&item_str))), + Some(format!("{} does not exist", item_path_for_api_in_room(&item_str, &room))), ) } else { const MAX_ITEM_BODY: usize = 10_000; @@ -1132,7 +1160,7 @@ pub async fn handle_rpc_batch( .map(|s| s.iter().cloned().collect()) .unwrap_or_default(); line_ok(RpcResult::GardenItem(ItemResponse { - item: item_str, + item: item_path_for_api_in_room(&item_str, &room), body, truncated, body_len, @@ -1525,7 +1553,11 @@ pub async fn handle_rpc_batch( let range = (top - bot).max(1e-12); for r in items { let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0)); - ranked.push(RankRow { item: item_path_for_api(r.item.as_str()), score: r.score, percent: pct }); + ranked.push(RankRow { + item: item_path_for_api_in_room(r.item.as_str(), &room), + score: r.score, + percent: pct, + }); } } @@ -1542,7 +1574,7 @@ pub async fn handle_rpc_batch( let page: Vec = ranked .into_iter() .chain(unranked.into_iter().map(|it| RankRow { - item: item_path_for_api(&it), + item: item_path_for_api_in_room(&it, &room), score: 0.0, percent: want_percent.then_some(0.0), })) @@ -1586,7 +1618,7 @@ pub async fn handle_rpc_batch( if !content.items.contains(&item) { line_err( "item not found", - Some(format!("{} does not exist", item_path_for_api(&item_str))), + Some(format!("{} does not exist", item_path_for_api_in_room(&item_str, &room))), ) } else { let votes: Vec = content @@ -1597,8 +1629,8 @@ pub async fn handle_rpc_batch( .take(limit) .map(|v| VoteRow { ts: v.ts, - a: v.a.as_str().to_string(), - b: v.b.as_str().to_string(), + a: item_path_for_api_in_room(v.a.as_str(), &room), + b: item_path_for_api_in_room(v.b.as_str(), &room), ratio: format!("{}:{}", v.ratio_left, v.ratio_right), actor: Some(v.principal.clone()), body: v.body.clone(), @@ -1608,7 +1640,7 @@ pub async fn handle_rpc_batch( }) .unwrap_or_default(); line_ok(RpcResult::Matchup(MatchupResponse { - item: item_path_for_api(&item_str), + item: item_path_for_api_in_room(&item_str, &room), votes, })) } @@ -1635,8 +1667,8 @@ pub async fn handle_rpc_batch( if a == item_str || b == item_str { Some(VoteRow { ts: e.ts, - a: item_path_for_api(&a), - b: item_path_for_api(&b), + a: item_path_for_api_in_room(&a, &room), + b: item_path_for_api_in_room(&b, &room), ratio: format!("{}:{}", ratio_left, ratio_right), actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| ing.principal.clone()), body: explanation, @@ -1672,7 +1704,7 @@ pub async fn handle_rpc_batch( } }).collect(); line_ok(RpcResult::RankHistory(RankHistoryResponse { - item: item_path_for_api(&item_str), + item: item_path_for_api_in_room(&item_str, &room), history, })) } @@ -1691,6 +1723,10 @@ pub async fn handle_rpc_batch( .map(|p| p.as_str().to_string()) .collect(); paths.sort(); + let paths: Vec = paths + .into_iter() + .map(|p| item_path_for_api_in_room(&p, &room)) + .collect(); line_ok(RpcResult::Leaves(LeavesResponse { paths })) } }, @@ -1707,10 +1743,21 @@ pub async fn handle_rpc_batch( let mut v: Vec = roots.iter() .map(|path| { let children = content.item_children.get(path.as_str()).map(|s| s.len()).unwrap_or(0); + let path_label = CanonicalItemUrl::parse(path.as_str()) + .and_then(|c| { + c.tilde_tail().map(|t| { + if t.is_empty() { + "~/".to_string() + } else { + format!("~/{}", t) + } + }) + }) + .unwrap_or_else(|| path.to_string()); PathSummary { - path: format!("~/{}", path), + path: path_label, children, - web: format!("https://slug.social/~/{}", path), + web: item_path_for_api_in_room(path.as_str(), &room), } }).collect(); v.sort_by(|a, b| a.path.cmp(&b.path)); @@ -1743,8 +1790,8 @@ pub async fn handle_rpc_batch( .take(limit) .map(|v| VoteRow { ts: v.ts, - a: v.a.as_str().to_string(), - b: v.b.as_str().to_string(), + a: item_path_for_api_in_room(v.a.as_str(), &room), + b: item_path_for_api_in_room(v.b.as_str(), &room), ratio: format!("{}:{}", v.ratio_left, v.ratio_right), actor: Some(v.principal.clone()), body: v.body.clone(), diff --git a/server/src/html/auth.rs b/server/src/html/auth.rs index d57b1c725ce3e06b1d904882b9388c3da48d85cb..5df77c76b89ed2bb11d936e1dbb804d50a0e79a1 100644 --- a/server/src/html/auth.rs +++ b/server/src/html/auth.rs @@ -23,7 +23,7 @@ fn form_inner(session: &str, error: Option<&str>) -> Markup { } } -pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup { +pub fn choose_username_page(session: &str, error: Option<&str>, theme: &str, theme_next: &str) -> Markup { let body = html! { nav.breadcrumb { a href="/" { "slug.social" } @@ -36,7 +36,7 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup { (form_inner(session, error)) } }; - super::layout("join — slug.social", "view-auth", body, None) + super::layout("join — slug.social", "view-auth", body, None, theme, theme_next) } /// Fragment returned to the poem JS on error — replaces the form's innerHTML. @@ -52,7 +52,7 @@ pub fn auth_signed_in_fragment() -> Markup { } } -pub fn auth_complete_page() -> Markup { +pub fn auth_complete_page(theme: &str, theme_next: &str) -> Markup { let body = html! { nav.breadcrumb { a href="/" { "slug.social" } @@ -63,5 +63,5 @@ pub fn auth_complete_page() -> Markup { p { "Return to your terminal — your agent is polling and will collect your token automatically." } p.auth-hint { "You can close this tab." } }; - super::layout("signed in — slug.social", "view-auth", body, None) + super::layout("signed in — slug.social", "view-auth", body, None, theme, theme_next) } diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs index 07f6ab3b778f07f60765fa3f39060ebc5f72bc1a..ecdd226b1b17d5f11d1b79f99add5759ef918281 100644 --- a/server/src/html/editor.rs +++ b/server/src/html/editor.rs @@ -1,8 +1,10 @@ use axum::{ extract::State, + http::Uri, response::{Html, IntoResponse}, Form, }; +use axum_extra::extract::cookie::CookieJar; use maud::{html, Markup}; use serde::Deserialize; @@ -13,7 +15,7 @@ use crate::{ state::AppState, }; -use super::{bc_segment, layout}; +use super::{bc_segment, layout, theme_from_jar, theme_next_from_uri}; fn bc_try() -> Markup { html! { @@ -23,7 +25,7 @@ fn bc_try() -> Markup { } /// The interactive editor page — `/try`. -pub async fn editor_page() -> impl IntoResponse { +pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse { let page = layout( "try — slug.social", "view-thread", @@ -68,6 +70,8 @@ pub async fn editor_page() -> impl IntoResponse { "#)) } }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()) } diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs index 5c5f9e2ba0e5cb1d661c113117988ec80c01deca..368b1016eb43d83e9ff39464ff6164fa9a8b558a 100644 --- a/server/src/html/forum.rs +++ b/server/src/html/forum.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, Uri}, response::{Html, IntoResponse}, }; use axum_extra::extract::cookie::CookieJar; @@ -19,7 +19,7 @@ use crate::{ use super::{ bc_segment, bc_threads, cli_panel, layout, now_ms, profile_href, recency_class, - render_linkified_with_embeds_in_scope, JsBuilder, + render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, JsBuilder, }; #[derive(Clone)] @@ -577,6 +577,7 @@ pub async fn home( State(state): State, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let now = now_ms(); let reduced = state.reduced.read().await; @@ -624,6 +625,8 @@ pub async fn home( (cli_panel("npx slugsocial public forum list")) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()) } @@ -679,6 +682,7 @@ async fn thread_view_inner( nav: ThreadNav, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let tag = canonicalize_tag(&tag); let scope = nav.scope(); @@ -778,6 +782,8 @@ async fn thread_view_inner( (cli_panel(&cli)) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()).into_response() } @@ -789,8 +795,9 @@ pub async fn thread_view( Query(q): Query, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { - thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar).await + thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar, uri).await } /// Room thread — `/r/:short/:slug/t/:tag` @@ -800,31 +807,39 @@ pub async fn room_thread_view( Query(q): Query, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } drop(reduced); let Some(nav) = ThreadNav::from_room_id(&room_id) else { return (StatusCode::NOT_FOUND, "bad room path").into_response(); }; - thread_view_inner(state, tag, q, nav, headers, jar) + thread_view_inner(state, tag, q, nav, headers, jar, uri) .await .into_response() } -fn room_not_found_page() -> impl IntoResponse { +fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoResponse { let body = html! { nav class="breadcrumb" { a href="/" { "slug.social" } } h1 { "not found" } p { "The requested page could not be found." } p { a href="/" { "home" } } }; - let page = layout("not found — slug.social", "view-thread", body, None); + let page = layout( + "not found — slug.social", + "view-thread", + body, + None, + theme_from_jar(jar), + &theme_next_from_uri(uri), + ); (StatusCode::NOT_FOUND, Html(page.into_string())) } @@ -834,6 +849,7 @@ pub async fn room_page( Path((room_short, room_slug)): Path<(String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let now = now_ms(); @@ -845,7 +861,7 @@ pub async fn room_page( let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } let scope = ScopeId::Room(room_id.clone()); let mut rows = collect_thread_rows_for_scope(&reduced, &scope, now); @@ -902,6 +918,8 @@ pub async fn room_page( (cli_panel(&audit_cli)) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()).into_response() } @@ -943,6 +961,8 @@ async fn thread_post_view_inner( index_str: String, nav: ThreadNav, viewer: Option, + jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let tag = canonicalize_tag(&tag); let index: usize = index_str.parse().unwrap_or(0); @@ -987,6 +1007,8 @@ async fn thread_post_view_inner( } }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()).into_response() } @@ -996,11 +1018,12 @@ pub async fn thread_post_view( Path((tag, index_str)): Path<(String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let reduced = state.reduced.read().await; let viewer = optional_principal(&headers, &jar, &reduced); drop(reduced); - thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer) + thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer, jar, uri) .await } @@ -1009,19 +1032,20 @@ pub async fn room_thread_post_view( Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } drop(reduced); let Some(nav) = ThreadNav::from_room_id(&room_id) else { return (StatusCode::NOT_FOUND, "bad room path").into_response(); }; - thread_post_view_inner(state, tag, index_str, nav, user) + thread_post_view_inner(state, tag, index_str, nav, user, jar, uri) .await .into_response() } @@ -1193,6 +1217,7 @@ pub async fn user_profile_page( Path(username): Path, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let canon = match parse_username(&username) { Ok(u) => u, @@ -1270,6 +1295,8 @@ pub async fn user_profile_page( (cli_panel(&format!("npx slugsocial public forum list"))) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()).into_response() } @@ -1292,13 +1319,14 @@ pub async fn room_thread_post_expand( Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } let Some(nav) = ThreadNav::from_room_id(&room_id) else { drop(reduced); @@ -1328,13 +1356,14 @@ pub async fn room_thread_post_expand_deleted( Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } let Some(nav) = ThreadNav::from_room_id(&room_id) else { drop(reduced); @@ -1364,13 +1393,14 @@ pub async fn room_thread_post_collapse_deleted( Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } let Some(nav) = ThreadNav::from_room_id(&room_id) else { drop(reduced); diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index e08f068ae04f5a2542ec2c43c240f931f87146bf..9b4789a79c3e293cbfc5f033a0eac8650320d94d 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, Uri}, response::{Html, IntoResponse}, }; use axum_extra::extract::cookie::CookieJar; @@ -20,7 +20,8 @@ use crate::{ use super::{ bc_path, bc_segment, cli_panel, layout, now_ms, ratio_pct, - render_linkified_with_embeds_in_scope, breadcrumb_path::OntologyPath, + render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, + breadcrumb_path::OntologyPath, forum::ThreadNav, }; @@ -59,14 +60,21 @@ fn scoped_bc_path(path: &OntologyPath, nav: &ThreadNav) -> maud::Markup { } } -fn room_not_found_page() -> impl IntoResponse { +fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoResponse { let body = html! { nav class="breadcrumb" { a href="/" { "slug.social" } } h1 { "not found" } p { "The requested page could not be found." } p { a href="/" { "home" } } }; - let page = layout("not found — slug.social", "view-thread", body, None); + let page = layout( + "not found — slug.social", + "view-thread", + body, + None, + theme_from_jar(jar), + &theme_next_from_uri(uri), + ); (StatusCode::NOT_FOUND, Html(page.into_string())) } @@ -81,7 +89,11 @@ fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&s } /// Ontology index — root-level paths. Private (UUID) roots are excluded. -pub async fn garden_index(State(state): State) -> impl IntoResponse { +pub async fn garden_index( + State(state): State, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { let nav = ThreadNav::public(); let child_rankings = { let reduced = state.reduced.read().await; @@ -130,6 +142,8 @@ pub async fn garden_index(State(state): State) -> impl IntoResponse { (cli_panel("npx slugsocial garden tree")) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()) } @@ -138,9 +152,11 @@ pub async fn garden_index(State(state): State) -> impl IntoResponse { pub async fn ontology_path( State(state): State, Path(path): Path, + jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let path = OntologyPath::from_input(&path); - render_scope_view(state, path, ThreadNav::public()).await + render_scope_view(state, path, ThreadNav::public(), jar, uri).await } pub async fn room_garden_index( @@ -148,19 +164,20 @@ pub async fn room_garden_index( Path((room_short, room_slug)): Path<(String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } drop(reduced); let Some(nav) = ThreadNav::from_room_id(&room_id) else { return (StatusCode::NOT_FOUND, "bad room path").into_response(); }; - render_scope_view(state, OntologyPath::root(), nav).await + render_scope_view(state, OntologyPath::root(), nav, jar, uri).await } pub async fn room_ontology_path( @@ -168,20 +185,21 @@ pub async fn room_ontology_path( Path((room_short, room_slug, path)): Path<(String, String, String)>, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); - return room_not_found_page().into_response(); + return room_not_found_page(&jar, &uri).into_response(); } drop(reduced); let Some(nav) = ThreadNav::from_room_id(&room_id) else { return (StatusCode::NOT_FOUND, "bad room path").into_response(); }; let path = OntologyPath::from_input(&path); - render_scope_view(state, path, nav).await + render_scope_view(state, path, nav, jar, uri).await } #[derive(Debug, Clone)] @@ -375,6 +393,8 @@ async fn render_scope_view( state: AppState, path: OntologyPath, nav: ThreadNav, + jar: CookieJar, + uri: Uri, ) -> axum::response::Response { let scope = nav.scope(); let model = { @@ -525,6 +545,8 @@ async fn render_scope_view( (cli_panel(&cli)) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()).into_response() diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index a8fd73433d120b07351dd027faf0b25f8f101321..53041a0cf0b30e6f20749c92ecc15a4e9ac56e09 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -1,10 +1,13 @@ use axum::{ body::Body, extract::Path, - http::{header, StatusCode}, + http::{header, HeaderValue, StatusCode, Uri}, response::{IntoResponse, Response}, + Form, }; +use axum_extra::extract::cookie::CookieJar; use maud::{html, Markup, DOCTYPE}; +use serde::Deserialize; use std::collections::HashSet; mod auth; @@ -33,6 +36,75 @@ pub(crate) fn profile_href(username: &str) -> String { format!("/u/{username}") } +/// Cookie name for UI theme (must match document.cookie migration in [`layout`]). +pub const SLUG_THEME_COOKIE: &str = "slug-theme"; + +/// Normalize a requested theme id to a known stylesheet key. +pub fn normalize_theme(raw: &str) -> &'static str { + match raw { + "retro" => "retro", + "retro_craft" => "retro_craft", + _ => "default", + } +} + +/// Resolved theme for rendering and cookie re-issue. +pub fn theme_from_jar(jar: &CookieJar) -> &'static str { + jar.get(SLUG_THEME_COOKIE) + .map(|c| normalize_theme(c.value())) + .unwrap_or("default") +} + +/// `Path` + optional `?query` for round-tripping after `POST /theme`. +pub fn theme_next_from_uri(uri: &Uri) -> String { + uri.path_and_query() + .map(|pq| pq.as_str().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "/".to_string()) +} + +/// `Set-Cookie` for a validated theme (ASCII cookie value). +pub fn theme_cookie_header_value(theme: &str) -> HeaderValue { + let t = normalize_theme(theme); + let s = format!("{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000"); + HeaderValue::from_str(&s).expect("theme cookie must be ASCII") +} + +/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme. +pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option { + let c = jar.get(SLUG_THEME_COOKIE)?; + Some(theme_cookie_header_value(c.value())) +} + +fn sanitize_theme_next(next: Option<&str>) -> String { + let s = next.unwrap_or("/").trim(); + if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 { + s.to_string() + } else { + "/".to_string() + } +} + +#[derive(Debug, Deserialize)] +pub struct ThemeForm { + theme: String, + next: Option, +} + +/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch). +pub async fn post_theme(Form(form): Form) -> impl IntoResponse { + let theme = normalize_theme(&form.theme); + let next = sanitize_theme_next(form.next.as_deref()); + let loc = HeaderValue::try_from(next.as_str()) + .unwrap_or_else(|_| HeaderValue::from_static("/")); + Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, loc) + .header(header::SET_COOKIE, theme_cookie_header_value(theme)) + .body(Body::empty()) + .expect("theme redirect response") +} + // Embed CSS files at compile time const THEME_DEFAULT_CSS: &str = include_str!("../../static/theme_default.css"); const THEME_RETRO_CSS: &str = include_str!("../../static/theme_retro.css"); @@ -173,7 +245,9 @@ impl JsQueryBuilder { } } -pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option) -> Markup { +pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option, theme: &str, theme_next: &str) -> Markup { + let theme = normalize_theme(theme); + let css_href = format!("/static/theme_{theme}.css"); html! { (DOCTYPE) html { @@ -181,7 +255,20 @@ pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option) meta charset="utf-8"; meta name="viewport" content="width=device-width, initial-scale=1"; title { (title) } - link rel="stylesheet" href="/static/theme_default.css" id="theme-stylesheet"; + script { (maud::PreEscaped(r#" +(function() { + try { + var ls = localStorage.getItem('slug-theme'); + if (!ls) return; + var m = document.cookie.match(/(?:^|;\s*)slug-theme=([^;]+)/); + var c = m ? decodeURIComponent(m[1].replace(/\+/g, ' ')) : ''; + if (ls === c) { localStorage.removeItem('slug-theme'); return; } + document.cookie = 'slug-theme=' + encodeURIComponent(ls) + '; Path=/; SameSite=Lax; Max-Age=31536000'; + location.reload(); + } catch (e) {} +})(); +"#)) } + link rel="stylesheet" href=(css_href) id="theme-stylesheet"; script src="https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js" {} } body class=(view) { @@ -196,32 +283,21 @@ pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option) input type="range" id="spread-slider" min="0" max="1" step="0.05" value="1"; } a id="search-btn" href="/search" { "search" } - div id="theme-switcher" { "theme" } + form id="slug-theme-form" method="post" action="/theme" { + input type="hidden" name="next" value=(theme_next); + select id="theme-select" name="theme" onchange="this.form.submit()" aria-label="Theme" { + @for (val, label) in [("default", "default"), ("retro", "retro"), ("retro_craft", "craft")] { + @if theme == val { + option value=(val) selected { (label) } + } @else { + option value=(val) { (label) } + } + } + } + } } script { (maud::PreEscaped(r#" (function() { - // Theme switching - const themes = ['default', 'retro', 'retro_craft']; - const themeLabel = { default: 'default', retro: 'retro', retro_craft: 'craft' }; - const storedTheme = localStorage.getItem('slug-theme') || 'default'; - const switcher = document.getElementById('theme-switcher'); - const stylesheet = document.getElementById('theme-stylesheet'); - - function setTheme(name) { - stylesheet.href = `/static/theme_${name}.css`; - localStorage.setItem('slug-theme', name); - switcher.textContent = themeLabel[name] || name; - setTimeout(() => setSpread(parseFloat(slider.value)), 50); - } - - setTheme(storedTheme); - - switcher.addEventListener('click', function() { - const current = themes.indexOf(localStorage.getItem('slug-theme') || 'default'); - const next = (current + 1) % themes.length; - setTheme(themes[next]); - }); - // Spread control const slider = document.getElementById('spread-slider'); const storedSpread = localStorage.getItem('slug-spread'); @@ -245,6 +321,7 @@ script { (maud::PreEscaped(r#" const f = e.target; if (!f || f.tagName !== 'FORM') return; if ((f.method || 'get').toLowerCase() !== 'post') return; + if (f.id === 'slug-theme-form') return; e.preventDefault(); const resp = await fetch(f.action, { method: 'POST', diff --git a/server/src/html/search.rs b/server/src/html/search.rs index 0871ddb7a87e3153e2cb6c1f827b46de2c9f0f3b..28ceaa53c3fcac6777311535e95fb771b19438f5 100644 --- a/server/src/html/search.rs +++ b/server/src/html/search.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Query, State}, - http::HeaderMap, + http::{HeaderMap, Uri}, response::{Html, IntoResponse}, }; use axum_extra::extract::cookie::CookieJar; @@ -15,7 +15,7 @@ use crate::{ timeago, }; -use super::{authorship_address, bc_segment, cli_panel, layout, now_ms}; +use super::{authorship_address, bc_segment, cli_panel, layout, now_ms, theme_from_jar, theme_next_from_uri}; /// Escape HTML special chars for safe injection. fn escape_html(s: &str) -> String { @@ -396,6 +396,7 @@ pub async fn search_page( Query(params): Query, headers: HeaderMap, jar: CookieJar, + uri: Uri, ) -> impl IntoResponse { let query = params.q.clone().unwrap_or_default(); let results = if query.len() >= 2 { @@ -420,6 +421,8 @@ pub async fn search_page( (cli_panel("npx slugsocial search ")) }, None, + theme_from_jar(&jar), + &theme_next_from_uri(&uri), ); Html(page.into_string()) } diff --git a/server/src/lib.rs b/server/src/lib.rs index 43b63293c2a62b9700948767c618cb0614ddf6d9..b9d4b79791ba8dd3a3c26ff777943a2548092de5 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -33,6 +33,7 @@ pub fn create_app(state: AppState) -> Router { .route("/post", post(api::post_web_ingest)) .route("/post/redact", post(api::post_web_redact)) .route("/post/check", post(api::check_web_ingest)) + .route("/theme", post(crate::html::post_theme)) .route("/sse", get(api::get_html_stream)) .route("/stream", get(api::get_stream)) .route("/search", get(crate::html::search_page)) diff --git a/server/src/path_types.rs b/server/src/path_types.rs index 997597f6659c1ba2092bc5348e393bf7a8fa7ae0..cad58cac7da948f92d6ea2a5587d917ab21d57ea 100644 --- a/server/src/path_types.rs +++ b/server/src/path_types.rs @@ -8,6 +8,12 @@ //! //! This module adds lightweight newtypes so code can be explicit about what it //! expects without changing core storage formats. +//! +//! **Storage vs wire:** [`CanonicalItemUrl`] values are shared across scopes +//! (`https://slug.social/~/…`); which [`crate::reducer::ContentState`] they live in +//! is determined by scope, not by embedding the room id in the string. For JSON/RPC +//! and browser links in a private room, use [`crate::api::helpers::item_path_for_api_in_room`] +//! so ontology items become `https://slug.social/r/{short}/{slug}/~/…`. use std::borrow::Borrow; use std::fmt; diff --git a/server/static/theme_default.css b/server/static/theme_default.css index 2b06b12f3e77c9ed305272fe3ab47a8ad08504df..5e6304bcc36caed8aeda913fd241fc8fb9cf6d25 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -678,7 +678,10 @@ details > summary::-webkit-details-marker { display: none; } gap: 6px; } #spread-slider { accent-color: var(--ui); width: 80px; } -#theme-switcher { +#slug-theme-form { + display: contents; +} +#theme-select { background: var(--g4); border: var(--bv) solid; border-color: var(--hi) var(--lo) var(--lo) var(--hi); @@ -688,8 +691,8 @@ details > summary::-webkit-details-marker { display: none; } padding: 2px 10px; user-select: none; } -#theme-switcher:hover { color: var(--signal); } -#theme-switcher:active { +#theme-select:hover { color: var(--signal); } +#theme-select:active { border-color: var(--lo) var(--hi) var(--hi) var(--lo); transform: translate(1px, 1px); } diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 508222443a3bb2f16d3034a07af53a7df37a0314..3fde7e2b77fa4e4f46418927eb0fb0e5cd195c75 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -35,7 +35,6 @@ h1, h2, h3 { font-weight: 600; letter-spacing: 0.14em; margin: 1.5rem 0 0.5rem; - text-transform: uppercase; } a { @@ -354,7 +353,10 @@ div.cli-panel { accent-color: var(--accent); width: 72px; } -#theme-switcher, +#slug-theme-form { + display: contents; +} +#theme-select, #search-btn, a#src-link { border: 1px solid var(--line); @@ -365,7 +367,7 @@ a#src-link { padding: 0.25rem 0.6rem; text-decoration: none; } -#theme-switcher:hover, +#theme-select:hover, #search-btn:hover, a#src-link:hover { border-color: var(--accent);