{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[deb79714] jsbuilder ofc\n\nSide A — unified diff (full patch):\ndiff --git a/mod.rs b/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..b1dfe895515a1ba94304c52ac4ea82ff6bb84c16\n--- /dev/null\n+++ b/mod.rs\n@@ -0,0 +1,929 @@\n+use axum::{\n+ body::Body,\n+ extract::Path,\n+ http::{header, HeaderValue, StatusCode, Uri},\n+ response::{IntoResponse, Response},\n+ Form,\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::{html, Markup, DOCTYPE};\n+use serde::Deserialize;\n+use std::collections::{HashMap, HashSet};\n+\n+mod auth;\n+mod breadcrumb_path;\n+mod editor;\n+mod forum;\n+mod garden;\n+pub mod routing;\n+mod search;\n+pub mod ui_action;\n+use breadcrumb_path::{ExternalOntologyPath, OntologyPath};\n+\n+pub use auth::{\n+ auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,\n+ choose_username_page,\n+};\n+pub use editor::{editor_check, editor_page};\n+pub use forum::{\n+ home, room_page, room_thread_post_view, room_thread_view, thread_feed_html,\n+ thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav,\n+};\n+\n+pub use forum::user_profile_page;\n+pub(crate) use forum::{\n+ fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,\n+ thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n+ user_can_post_room, user_can_view_room,\n+};\n+pub(crate) use garden::{\n+ encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,\n+ GARDEN_PIN_COOKIE,\n+};\n+pub use garden::{\n+ external_garden_index, external_ontology_path, garden_index, ontology_path,\n+ room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,\n+ room_vote_compare_page, vote_compare_page,\n+};\n+pub use routing::RouteContext;\n+pub use search::{search_page, search_results_fragment};\n+pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};\n+\n+/// Public profile URL path for a stored username (no `@`).\n+pub(crate) fn profile_href(username: &str) -> String {\n+ format!(\"/u/{username}\")\n+}\n+\n+/// Cookie name for UI theme (must match document.cookie migration in [`layout`]).\n+pub const SLUG_THEME_COOKIE: &str = \"slug-theme\";\n+\n+/// Normalize a requested theme id to a known stylesheet key.\n+pub fn normalize_theme(raw: &str) -> &'static str {\n+ match raw {\n+ \"retro\" => \"retro\",\n+ \"retro_craft\" => \"retro_craft\",\n+ _ => \"default\",\n+ }\n+}\n+\n+/// Resolved theme for rendering and cookie re-issue.\n+pub fn theme_from_jar(jar: &CookieJar) -> &'static str {\n+ jar.get(SLUG_THEME_COOKIE)\n+ .map(|c| normalize_theme(c.value()))\n+ .unwrap_or(\"default\")\n+}\n+\n+/// `Path` + optional `?query` for round-tripping after `POST /theme`.\n+pub fn theme_next_from_uri(uri: &Uri) -> String {\n+ uri.path_and_query()\n+ .map(|pq| pq.as_str().to_string())\n+ .filter(|s| !s.is_empty())\n+ .unwrap_or_else(|| \"/\".to_string())\n+}\n+\n+/// `Set-Cookie` for a validated theme (ASCII cookie value).\n+pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {\n+ let t = normalize_theme(theme);\n+ let s = format!(\"{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000\");\n+ HeaderValue::from_str(&s).expect(\"theme cookie must be ASCII\")\n+}\n+\n+/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme.\n+pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option {\n+ let c = jar.get(SLUG_THEME_COOKIE)?;\n+ Some(theme_cookie_header_value(c.value()))\n+}\n+\n+fn sanitize_theme_next(next: Option<&str>) -> String {\n+ let s = next.unwrap_or(\"/\").trim();\n+ if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n+ s.to_string()\n+ } else {\n+ \"/\".to_string()\n+ }\n+}\n+\n+#[derive(Debug, Deserialize)]\n+pub struct ThemeForm {\n+ theme: String,\n+ next: Option,\n+}\n+\n+/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch).\n+pub async fn post_theme(Form(form): Form) -> impl IntoResponse {\n+ let theme = normalize_theme(&form.theme);\n+ let next = sanitize_theme_next(form.next.as_deref());\n+ let loc =\n+ HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n+ Response::builder()\n+ .status(StatusCode::SEE_OTHER)\n+ .header(header::LOCATION, loc)\n+ .header(header::SET_COOKIE, theme_cookie_header_value(theme))\n+ .body(Body::empty())\n+ .expect(\"theme redirect response\")\n+}\n+\n+// Embed CSS and shared UI script at compile time\n+const THEME_DEFAULT_CSS: &str = include_str!(\"../../static/theme_default.css\");\n+const THEME_RETRO_CSS: &str = include_str!(\"../../static/theme_retro.css\");\n+const THEME_RETRO_CRAFT_CSS: &str = include_str!(\"../../static/theme_retro_craft.css\");\n+const SLUG_UI_JS: &str = include_str!(\"../../static/slug_ui.js\");\n+\n+pub async fn serve_static(Path(filename): Path) -> impl IntoResponse {\n+ if filename == \"slug_ui.js\" {\n+ return Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n+ .body(SLUG_UI_JS.to_string())\n+ .unwrap()\n+ .into_response();\n+ }\n+\n+ let theme = filename\n+ .strip_prefix(\"theme_\")\n+ .and_then(|s| s.strip_suffix(\".css\"));\n+\n+ let css = match theme {\n+ Some(\"default\") => THEME_DEFAULT_CSS,\n+ Some(\"retro\") => THEME_RETRO_CSS,\n+ Some(\"retro_craft\") => THEME_RETRO_CRAFT_CSS,\n+ _ => return (StatusCode::NOT_FOUND, \"static file not found\").into_response(),\n+ };\n+\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/css; charset=utf-8\")\n+ .header(header::CACHE_CONTROL, \"public, max-age=3600\")\n+ .body(css.to_string())\n+ .unwrap()\n+ .into_response()\n+}\n+\n+pub(crate) fn js_string_literal(s: &str) -> String {\n+ serde_json::to_string(s).expect(\"javascript string escaping\")\n+}\n+\n+/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures.\n+pub(crate) fn ui_js_warn(msg: &str) -> Response {\n+ let js = format!(\"console.warn({});\", js_string_literal(msg));\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(Body::from(js))\n+ .unwrap()\n+}\n+\n+pub(crate) struct JsBuilder {\n+ snippets: Vec,\n+}\n+\n+pub(crate) struct JsQueryBuilder {\n+ builder: JsBuilder,\n+ expr: String,\n+}\n+\n+impl JsBuilder {\n+ pub(crate) fn new() -> Self {\n+ Self {\n+ snippets: Vec::new(),\n+ }\n+ }\n+\n+ pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self {\n+ self.morph_expr(\n+ &format!(\"document.querySelector({})\", js_string_literal(selector)),\n+ markup,\n+ None,\n+ )\n+ }\n+\n+ /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`.\n+ pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self {\n+ self.qs(selector).morph_inner(markup)\n+ }\n+\n+ pub(crate) fn morph_expr(\n+ mut self,\n+ expr: &str,\n+ markup: Markup,\n+ morph_style: Option<&str>,\n+ ) -> Self {\n+ let html = js_string_literal(&markup.into_string());\n+ let opts = morph_style\n+ .map(|style| format!(\", {{morphStyle: {}}}\", js_string_literal(style)))\n+ .unwrap_or_default();\n+ self.snippets.push(format!(\n+ \"var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}\",\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder {\n+ JsQueryBuilder {\n+ builder: self,\n+ expr: format!(\"document.querySelector({})\", js_string_literal(selector)),\n+ }\n+ }\n+\n+ pub(crate) fn id(self, id: &str) -> JsQueryBuilder {\n+ self.qs(&format!(\"#{id}\"))\n+ }\n+\n+ pub(crate) fn if_current_path_matches(\n+ mut self,\n+ path: &str,\n+ f: impl FnOnce(JsBuilder) -> JsBuilder,\n+ ) -> Self {\n+ let inner = f(JsBuilder::new()).build();\n+ self.snippets.push(format!(\n+ \"var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}\",\n+ path = js_string_literal(path),\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn if_current_path_not_matches(\n+ mut self,\n+ path: &str,\n+ f: impl FnOnce(JsBuilder) -> JsBuilder,\n+ ) -> Self {\n+ let inner = f(JsBuilder::new()).build();\n+ self.snippets.push(format!(\n+ \"var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}\",\n+ path = js_string_literal(path),\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn redirect(mut self, to: &str) -> Self {\n+ self.snippets\n+ .push(format!(\"window.location = {};\", js_string_literal(to)));\n+ self\n+ }\n+\n+ pub(crate) fn clipboard_write_text_and_label_btn(\n+ mut self,\n+ text: &str,\n+ btn_id: &str,\n+ copied_label: &str,\n+ ) -> Self {\n+ self.snippets.push(format!(\n+ \"navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});\",\n+ text = js_string_literal(text),\n+ btn_id = js_string_literal(btn_id),\n+ label = js_string_literal(copied_label),\n+ ));\n+ self\n+ }\n+\n+ /// Focus first matching element (e.g. after morphing open a compose form).\n+ pub(crate) fn focus_selector(mut self, selector: &str) -> Self {\n+ self.snippets.push(format!(\n+ \"var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}\",\n+ js_string_literal(selector),\n+ ));\n+ self\n+ }\n+\n+ pub(crate) fn build(self) -> String {\n+ self.snippets.join(\" \")\n+ }\n+\n+ pub(crate) fn into_response(self) -> Response {\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(Body::from(self.build()))\n+ .unwrap()\n+ }\n+}\n+\n+impl JsQueryBuilder {\n+ pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder {\n+ let html = js_string_literal(&markup.into_string());\n+ self.builder.snippets.push(format!(\n+ \"var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}\",\n+ expr = self.expr,\n+ ));\n+ self.builder\n+ }\n+\n+ pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder {\n+ let html = js_string_literal(&markup.into_string());\n+ self.builder.snippets.push(format!(\n+ \"var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}\",\n+ expr = self.expr,\n+ ));\n+ self.builder\n+ }\n+\n+ pub(crate) fn reset(mut self) -> JsBuilder {\n+ self.builder.snippets.push(format!(\n+ \"var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}\",\n+ expr = self.expr,\n+ ));\n+ self.builder\n+ }\n+}\n+\n+#[allow(clippy::too_many_arguments)]\n+pub(super) fn layout(\n+ title: &str,\n+ view: &str,\n+ body: Markup,\n+ views: Option,\n+ theme: &str,\n+ theme_next: &str,\n+ garden_room_wire: Option<&str>,\n+ garden_path_prefix: Option<&str>,\n+) -> Markup {\n+ layout_embed_controls(\n+ title,\n+ view,\n+ body,\n+ views,\n+ theme,\n+ theme_next,\n+ garden_room_wire,\n+ garden_path_prefix,\n+ true,\n+ )\n+}\n+\n+/// Minimal document shell: no bottom controls, no garden HUD data attributes (`data-garden-room` /\n+/// `data-garden-prefix` empty). For routes that own the full viewport (e.g. vote compare).\n+pub(super) fn layout_full_bleed_chromeless(\n+ title: &str,\n+ view: &str,\n+ body: Markup,\n+ views: Option,\n+ theme: &str,\n+ theme_next: &str,\n+) -> Markup {\n+ layout_embed_controls(\n+ title, view, body, views, theme, theme_next, None, None, false,\n+ )\n+}\n+\n+#[allow(clippy::too_many_arguments)]\n+fn layout_embed_controls(\n+ title: &str,\n+ view: &str,\n+ body: Markup,\n+ views: Option,\n+ theme: &str,\n+ theme_next: &str,\n+ garden_room_wire: Option<&str>,\n+ garden_path_prefix: Option<&str>,\n+ show_controls: bool,\n+) -> Markup {\n+ let theme = normalize_theme(theme);\n+ let css_href = format!(\"/static/theme_{theme}.css\");\n+ html! {\n+ (DOCTYPE)\n+ html {\n+ head {\n+ meta charset=\"utf-8\";\n+ meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n+ title { (title) }\n+ link rel=\"stylesheet\" href=(css_href) id=\"theme-stylesheet\";\n+ script src=\"https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js\" {}\n+ }\n+ body class=(view)\n+ data-garden-room=(garden_room_wire.unwrap_or(\"\"))\n+ data-garden-prefix=(garden_path_prefix.unwrap_or(\"\")) {\n+ @if let Some(n) = views {\n+ span class=\"view-meta muted\" { (n) \" views\" }\n+ }\n+ div id=\"errors\" {}\n+ (body)\n+ @if show_controls {\n+ div id=\"controls\" {\n+ a href=\"https://github.com/sortersocial/slug\" id=\"src-link\" { \"src\" }\n+ div id=\"spread-control\" {\n+ span { \"spread\" }\n+ input type=\"range\" id=\"spread-slider\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\";\n+ }\n+ @if let (Some(gr), Some(ref gpx)) = (garden_room_wire, garden_path_prefix) {\n+ @if !gr.is_empty() {\n+ div id=\"slug-pin-hud\" class=\"slug-pin-hud\" data-garden-prefix=(gpx) {}\n+ }\n+ }\n+ a id=\"search-btn\" href=\"/search\" { \"search\" }\n+ form id=\"slug-theme-form\" method=\"post\" action=\"/theme\" {\n+ input type=\"hidden\" name=\"next\" value=(theme_next);\n+ select id=\"theme-select\" name=\"theme\" onchange=\"this.form.submit()\" aria-label=\"Theme\" {\n+ @for (val, label) in [(\"default\", \"default\"), (\"retro\", \"retro\"), (\"retro_craft\", \"craft\")] {\n+ @if theme == val {\n+ option value=(val) selected { (label) }\n+ } @else {\n+ option value=(val) { (label) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ script src=\"/static/slug_ui.js\" {}\n+ }\n+ }\n+ }\n+}\n+\n+pub(super) fn now_ms() -> i64 {\n+ let t = std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap_or_default();\n+ t.as_millis() as i64\n+}\n+\n+pub(super) fn ratio_pct(left: i32, right: i32) -> f64 {\n+ let l = (left.max(0)) as f64;\n+ let r = (right.max(0)) as f64;\n+ let denom = l + r;\n+ if denom <= 0.0 {\n+ return 50.0;\n+ }\n+ (l / denom) * 100.0\n+}\n+\n+/// Render a single breadcrumb segment with `/` separator.\n+pub(super) fn bc_segment(label: &str, href: &str, is_current: bool) -> Markup {\n+ html! {\n+ span class=\"bc-sep\" { \" / \" }\n+ @if is_current {\n+ a href=(href) class=\"bc-current\" { (label) }\n+ } @else {\n+ a href=(href) { (label) }\n+ }\n+ }\n+}\n+\n+/// Breadcrumb for external ontology `/-/https://…`\n+fn bc_path_external(path: &ExternalOntologyPath) -> Markup {\n+ html! {\n+ a href=\"/\" { \"slug.social\" }\n+ (bc_segment(\"-\", \"/-\", path.is_root()))\n+ @for (i, id) in path.breadcrumb_chain().iter().enumerate() {\n+ @let disp = id.display_path();\n+ @let tail = disp.strip_prefix(\"-/\").unwrap_or(disp.as_str());\n+ @let href = format!(\"/-/{}\", tail);\n+ @let is_last = i + 1 == path.breadcrumb_chain().len();\n+ (bc_segment(id.last_segment(), &href, is_last))\n+ }\n+ }\n+}\n+\n+/// Breadcrumb for any ontology path segment, e.g. \"parables\" or \"parables/counting-the-cost\".\n+fn bc_path(path: &OntologyPath) -> Markup {\n+ html! {\n+ a href=(path.slug_root_href()) { \"slug.social\" }\n+ (bc_segment(\"~\", \"/~\", path.is_root()))\n+ @for (i, seg) in path.segments().iter().enumerate() {\n+ @let href = format!(\"/~/{}\", path.segments()[..=i].join(\"/\"));\n+ @let is_last = i == path.segments().len() - 1;\n+ (bc_segment(seg, &href, is_last))\n+ }\n+ }\n+}\n+\n+/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post.\n+/// Root link toggles to `/~` only at thread-root (`/`).\n+pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup {\n+ let root_href = if thread_tag.is_some() { \"/\" } else { \"/~\" };\n+ let root_is_current = thread_tag.is_none();\n+ html! {\n+ @if root_is_current {\n+ a href=(root_href) class=\"bc-current\" { \"slug.social\" }\n+ } @else {\n+ a href=(root_href) { \"slug.social\" }\n+ }\n+ @if let Some(tag) = thread_tag {\n+ @if let Some(idx) = focused_post {\n+ (bc_segment(&format!(\"#{tag}\"), &format!(\"/t/{tag}\"), false))\n+ (bc_segment(&format!(\"post #{idx}\"), &format!(\"/t/{tag}/{idx}\"), true))\n+ } @else {\n+ (bc_segment(&format!(\"#{tag}\"), &format!(\"/t/{tag}\"), true))\n+ }\n+ }\n+ }\n+}\n+\n+/// Short display label for a stored agent id (`uuid:rig:model`, no `@`).\n+pub(super) fn actor_label(agent_naked: &str) -> String {\n+ let a = agent_naked.trim();\n+ let parts: Vec<&str> = a.split(':').collect();\n+ if parts.len() >= 3 {\n+ let rig = parts[1].trim();\n+ let model = parts[2].trim();\n+ // X actors: show @handle instead of uuid hash.\n+ if rig == \"x.com\" {\n+ return format!(\"@{model}\");\n+ }\n+ let uuid = parts[0].trim();\n+ let uuid8 = uuid.chars().take(8).collect::();\n+ if !uuid8.is_empty() && !rig.is_empty() && !model.is_empty() {\n+ return format!(\"{uuid8}:{rig}:{model}\");\n+ }\n+ }\n+ a.to_string()\n+}\n+\n+/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present.\n+pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String {\n+ match delegate {\n+ Some(d) => format!(\"@@{}\", actor_label(d)),\n+ None => format!(\"@{principal}\"),\n+ }\n+}\n+\n+/// Escape HTML special chars for safe injection.\n+fn escape_html(s: &str) -> String {\n+ let mut out = String::with_capacity(s.len());\n+ for c in s.chars() {\n+ match c {\n+ '&' => out.push_str(\"&\"),\n+ '<' => out.push_str(\"<\"),\n+ '>' => out.push_str(\">\"),\n+ '\"' => out.push_str(\""\"),\n+ _ => out.push(c),\n+ }\n+ }\n+ out\n+}\n+\n+/// Max characters from an item body placed in a `title` tooltip (native hover).\n+const ITEM_LINK_TITLE_MAX_CHARS: usize = 500;\n+\n+fn collapse_whitespace_for_title(s: &str) -> String {\n+ let mut out = String::with_capacity(s.len().min(ITEM_LINK_TITLE_MAX_CHARS + 8));\n+ let mut last_was_space = true;\n+ for c in s.chars() {\n+ if c.is_whitespace() {\n+ if !last_was_space {\n+ out.push(' ');\n+ last_was_space = true;\n+ }\n+ } else {\n+ out.push(c);\n+ last_was_space = false;\n+ }\n+ }\n+ out.trim().to_string()\n+}\n+\n+fn item_body_title_snippet(body: &str) -> Option {\n+ let s = collapse_whitespace_for_title(body);\n+ if s.is_empty() {\n+ return None;\n+ }\n+ let truncated: String = s.chars().take(ITEM_LINK_TITLE_MAX_CHARS).collect();\n+ let ellipsis = if s.chars().count() > ITEM_LINK_TITLE_MAX_CHARS {\n+ \"…\"\n+ } else {\n+ \"\"\n+ };\n+ Some(format!(\"{truncated}{ellipsis}\"))\n+}\n+\n+fn garden_href_for_item_ref(\n+ raw_ref: &str,\n+ garden_prefix: &str,\n+) -> Option<(crate::path_types::ItemId, String)> {\n+ let key = slug_types::canonicalize_item(raw_ref);\n+ let id = crate::path_types::ItemId::parse(&key)?;\n+ let href = if let Some(tail) = id.tilde_tail() {\n+ if tail.is_empty() {\n+ garden_prefix.trim_end_matches('/').to_string()\n+ } else {\n+ format!(\"{}/{}\", garden_prefix.trim_end_matches('/'), tail)\n+ }\n+ } else if id.as_str().starts_with(\"https://\") || id.as_str().starts_with(\"http://\") {\n+ let display = id.display_path();\n+ let rest = display.strip_prefix(\"-/\").unwrap_or(display.as_str());\n+ let ext_prefix = format!(\"{}-\", garden_prefix.trim_end_matches('~'));\n+ format!(\"{ext_prefix}/{rest}\")\n+ } else {\n+ return None;\n+ };\n+ Some((id, href))\n+}\n+\n+fn push_item_ref_anchor(\n+ out: &mut String,\n+ raw_ref: &str,\n+ garden_prefix: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> bool {\n+ let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else {\n+ return false;\n+ };\n+ out.push_str(r#\"');\n+ out.push_str(&escape_html(raw_ref));\n+ out.push_str(\"\");\n+ true\n+}\n+\n+/// Replace item refs in raw prose with clickable garden links.\n+///\n+/// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated\n+/// body preview for native browser tooltips (forum posts, item pages).\n+pub(super) fn linkify_slugs_with_prefix(\n+ raw: &str,\n+ garden_prefix: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> String {\n+ let mut out = String::with_capacity(raw.len() + 64);\n+ for token in crate::dsl::tokenize_prose_item_refs(raw) {\n+ match token {\n+ crate::dsl::ProseToken::Text(text) => out.push_str(&escape_html(&text)),\n+ crate::dsl::ProseToken::ItemRef(raw_ref) => {\n+ if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) {\n+ out.push_str(&escape_html(&raw_ref));\n+ }\n+ }\n+ }\n+ }\n+ out\n+}\n+\n+#[derive(Clone)]\n+struct EmbedFrame {\n+ src: String,\n+ title: String,\n+ provider_class: &'static str,\n+}\n+\n+fn clean_media_id(s: &str) -> String {\n+ s.chars()\n+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')\n+ .collect()\n+}\n+\n+fn query_param(url: &str, name: &str) -> Option {\n+ let q = url.split_once('?')?.1;\n+ for pair in q.split('&') {\n+ let (k, v) = pair.split_once('=').unwrap_or((pair, \"\"));\n+ if k == name {\n+ return Some(v.to_string());\n+ }\n+ }\n+ None\n+}\n+\n+fn spotify_embed_src(url: &str) -> Option {\n+ let rest = url\n+ .strip_prefix(\"https://\")\n+ .or_else(|| url.strip_prefix(\"http://\"))?;\n+ let (host, tail) = rest.split_once('/').unwrap_or((rest, \"\"));\n+ let host = host.to_lowercase();\n+ if !(host == \"open.spotify.com\" || host == \"www.open.spotify.com\") {\n+ return None;\n+ }\n+ let path = tail\n+ .split('#')\n+ .next()\n+ .unwrap_or(tail)\n+ .split('?')\n+ .next()\n+ .unwrap_or(tail);\n+ let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();\n+ if segs.first().is_some_and(|s| s.starts_with(\"intl-\")) {\n+ segs.remove(0);\n+ }\n+ if segs.len() < 2 {\n+ return None;\n+ }\n+ let kind = segs[0];\n+ let id = clean_media_id(segs[1]);\n+ if id.is_empty() {\n+ return None;\n+ }\n+ let allowed = matches!(kind, \"track\" | \"album\" | \"playlist\" | \"episode\" | \"show\");\n+ if !allowed {\n+ return None;\n+ }\n+ Some(EmbedFrame {\n+ src: format!(\"https://open.spotify.com/embed/{kind}/{id}\"),\n+ title: format!(\"Spotify {kind}\"),\n+ provider_class: \"embed-spotify\",\n+ })\n+}\n+\n+fn youtube_embed_src(url: &str) -> Option {\n+ let rest = url\n+ .strip_prefix(\"https://\")\n+ .or_else(|| url.strip_prefix(\"http://\"))?;\n+ let (host, tail) = rest.split_once('/').unwrap_or((rest, \"\"));\n+ let host = host.to_lowercase();\n+\n+ let video_id = if host == \"youtu.be\" || host == \"www.youtu.be\" {\n+ clean_media_id(\n+ tail.split(['?', '#'])\n+ .next()\n+ .unwrap_or(tail)\n+ .trim_matches('/'),\n+ )\n+ } else if matches!(\n+ host.as_str(),\n+ \"youtube.com\" | \"www.youtube.com\" | \"m.youtube.com\" | \"music.youtube.com\"\n+ ) {\n+ let path = format!(\"/{}\", tail.split('#').next().unwrap_or(tail));\n+ if path.starts_with(\"/watch\") {\n+ clean_media_id(&query_param(url, \"v\")?)\n+ } else if let Some(id) = path.strip_prefix(\"/shorts/\") {\n+ clean_media_id(id.split(['?', '/']).next().unwrap_or(id))\n+ } else if let Some(id) = path.strip_prefix(\"/embed/\") {\n+ clean_media_id(id.split(['?', '/']).next().unwrap_or(id))\n+ } else {\n+ String::new()\n+ }\n+ } else {\n+ String::new()\n+ };\n+\n+ if video_id.is_empty() {\n+ return None;\n+ }\n+ Some(EmbedFrame {\n+ src: format!(\"https://www.youtube.com/embed/{video_id}\"),\n+ title: \"YouTube video\".to_string(),\n+ provider_class: \"embed-youtube\",\n+ })\n+}\n+\n+fn extract_embed_frames(raw: &str) -> Vec {\n+ let mut out = Vec::new();\n+ let mut seen = HashSet::new();\n+ for token in raw.split_whitespace() {\n+ let token = token\n+ .trim_matches(|c: char| c == '(' || c == '[' || c == '{' || c == '\"' || c == '\\'')\n+ .trim_end_matches(|c: char| \".,;:!?)]}\\\"'\".contains(c));\n+ if !(token.starts_with(\"https://\") || token.starts_with(\"http://\")) {\n+ continue;\n+ }\n+ let embed = spotify_embed_src(token).or_else(|| youtube_embed_src(token));\n+ if let Some(frame) = embed {\n+ if seen.insert(frame.src.clone()) {\n+ out.push(frame);\n+ }\n+ }\n+ }\n+ out\n+}\n+\n+pub(super) fn render_linkified_with_embeds_in_scope(\n+ raw: &str,\n+ garden_prefix: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> Markup {\n+ let embeds = extract_embed_frames(raw);\n+ html! {\n+ pre { (maud::PreEscaped(linkify_slugs_with_prefix(raw, garden_prefix, item_bodies))) }\n+ @if !embeds.is_empty() {\n+ div class=\"rich-embeds\" {\n+ @for e in embeds {\n+ div class=(format!(\"rich-embed {}\", e.provider_class)) {\n+ iframe\n+ src=(e.src)\n+ title=(e.title)\n+ loading=\"lazy\"\n+ allow=\"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share\"\n+ referrerpolicy=\"strict-origin-when-cross-origin\"\n+ allowfullscreen {}\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+/// Item page / thread body: resolver-specific rich HTML, else linkified `
` + media embeds.\n+pub(super) fn render_item_body_in_scope(\n+    raw: &str,\n+    garden_prefix: &str,\n+    item_bodies: Option<&HashMap>,\n+) -> Markup {\n+    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {\n+        return html! {\n+            div class=\"item-body-rich\" { (m) }\n+        };\n+    }\n+    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)\n+}\n+\n+/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.\n+fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {\n+    assert!(\n+        !s.contains('\\\\') && !s.contains('\\'') && !s.contains('\\n') && !s.contains('\\r'),\n+        \"cli_panel cmd must not contain `\\\\`, `'`, or newlines (got {s:?})\"\n+    );\n+}\n+\n+/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.\n+pub(super) fn cli_panel>(cmds: &[I]) -> Markup {\n+    if cmds.is_empty() {\n+        return html! {};\n+    }\n+    for cmd in cmds {\n+        assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());\n+    }\n+    html! {\n+        div class=\"cli-panel\" {\n+            span class=\"cli-panel-label muted\" { \"cli\" }\n+            div class=\"cli-panel-cmds\" {\n+                @for cmd in cmds {\n+                    @let s = cmd.as_ref();\n+                    button type=\"button\" class=\"cli-panel-row\" title=\"Copy command\" onclick=(format!(\n+                        r#\"navigator.clipboard.writeText('{}');\"#,\n+                        s\n+                    )) {\n+                        code class=\"cli-panel-cmd\" { (s) }\n+                    }\n+                }\n+            }\n+        }\n+    }\n+}\n+\n+/// Age bucket for recency coloring of thread entries.\n+pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {\n+    let age_ms = now_ms.saturating_sub(ts_ms);\n+    let age_secs = age_ms / 1000;\n+    if age_secs < 3600 {\n+        \"age-fresh\" // < 1 hour\n+    } else if age_secs < 86400 {\n+        \"age-recent\" // < 1 day\n+    } else if age_secs < 86400 * 7 {\n+        \"age-week\" // < 1 week\n+    } else {\n+        \"age-old\" // >= 1 week\n+    }\n+}\n+\n+#[cfg(test)]\n+mod linkify_title_tests {\n+    use super::*;\n+    use crate::path_types::ItemId;\n+    use std::collections::HashMap;\n+\n+    #[test]\n+    fn tilde_link_gets_title_from_item_bodies() {\n+        let mut bodies = HashMap::new();\n+        let key = ItemId::parse(&slug_types::canonicalize_item(\"~/foo/bar\")).unwrap();\n+        bodies.insert(key, \"Hello  world\\nline\".to_string());\n+        let html = linkify_slugs_with_prefix(\"see ~/foo/bar ok\", \"/r/x/~\", Some(&bodies));\n+        assert!(html.contains(\"title=\\\"Hello world line\\\"\"));\n+        assert!(html.contains(\"href=\\\"/r/x/~/foo/bar\\\"\"));\n+    }\n+\n+    #[test]\n+    fn raw_url_links_to_public_external_garden_page() {\n+        let html = linkify_slugs_with_prefix(\"see https://example.com/z.\", \"/~\", None);\n+        assert!(html.contains(\n+            r#\"https://example.com/z.\"#\n+        ));\n+    }\n+\n+    #[test]\n+    fn dash_ref_links_to_room_external_garden_page_with_title() {\n+        let mut bodies = HashMap::new();\n+        let key = ItemId::parse(&slug_types::canonicalize_item(\"-/example.com/z\")).unwrap();\n+        bodies.insert(key, \"External body\\npreview\".to_string());\n+        let html =\n+            linkify_slugs_with_prefix(\"see -/example.com/z\", \"/r/9ab12cdroom/~\", Some(&bodies));\n+        assert!(html.contains(r#\"href=\"/r/9ab12cdroom/-/https://example.com/z\"\"#));\n+        assert!(html.contains(r#\"title=\"External body preview\"\"#));\n+    }\n+\n+    #[test]\n+    fn code_fence_urls_are_not_linkified() {\n+        let html = linkify_slugs_with_prefix(\n+            \"```json\\n{\\\"url\\\":\\\"https://example.com/z\\\"}\\n```\\nthen https://example.com/a\",\n+            \"/~\",\n+            None,\n+        );\n+        assert!(!html.contains(r#\"href=\"/-/https://example.com/z\"\"#));\n+        assert!(html.contains(r#\"href=\"/-/https://example.com/a\"\"#));\n+    }\n+\n+    #[test]\n+    fn no_title_when_body_missing_or_empty() {\n+        let html = linkify_slugs_with_prefix(\"x ~/a/b y\", \"/~\", Some(&HashMap::new()));\n+        assert!(!html.contains(\" title=\"));\n+    }\n+}\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[df769734] Pin vote controls in a compact bottom HUD on compare pages.\n\nKeeps ratio, slider, and actions always visible without repeating item titles beside the slider.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 1822528e3e3b526059899349ba2f73ae0666f944..dcf05df143825f4f8ba9ee3b3b5f8ab2e69950c7 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -528,5 +528,6 @@ mod tests {\n         assert!(SORTER_UI_JS.contains(\"Math.max(1, v)\"));\n         assert!(SORTER_UI_JS.contains(\"var divisor = gcd(left, right)\"));\n         assert!(SORTER_UI_JS.contains(\"ratioDisplay.textContent = left + ':' + right\"));\n+        assert!(SORTER_UI_JS.contains(\"--vote-slider-pct\"));\n     }\n }\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nindex 7be4be152ea331fb7afb7ce598aed27d23e35cc4..872d23ddf6c18fab016a624de85736fdc2dcde63 100644\n--- a/server/src/html/vote.rs\n+++ b/server/src/html/vote.rs\n@@ -134,6 +134,36 @@ fn vote_back_nav(parent: &ItemId) -> Markup {\n     }\n }\n \n+fn vote_hud_form(\n+    parent: &ItemId,\n+    left: &ItemId,\n+    right: &ItemId,\n+    rpc_json: &str,\n+    next_pair: Option<&(ItemId, ItemId)>,\n+) -> Markup {\n+    html! {\n+        div id=\"vote-hud\" class=\"vote-hud\" role=\"region\" aria-label=\"Vote controls\" {\n+            form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n+                input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+                input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"1\";\n+                input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"1\";\n+                div class=\"vote-hud-inner\" {\n+                    div class=\"vote-ratio-readout\" {\n+                        span class=\"muted small\" { \"ratio \" }\n+                        strong id=\"vote-ratio-display\" { \"1:1\" }\n+                    }\n+                    label class=\"vote-hud-slider\" {\n+                        input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+                            aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"50\"\n+                            aria-label=(format!(\"Preference: {} vs {}\", left.as_str(), right.as_str()));\n+                    }\n+                    (vote_compare_actions(parent, next_pair))\n+                }\n+            }\n+        }\n+    }\n+}\n+\n fn vote_compare_actions(parent: &ItemId, next: Option<&(ItemId, ItemId)>) -> Markup {\n     let next_href = next.map(|(l, r)| vote_compare_href(parent, l, r));\n     html! {\n@@ -250,41 +280,28 @@ pub async fn vote_page(\n     );\n \n     let body = html! {\n-        div class=\"scope-theme vote-page-grid\" style=(scope_theme_style(&parent)) {\n-            section class=\"vote-compare-shell\" {\n-                h1 { \"compare\" }\n-                (breadcrumb_path(&parent))\n-                p class=\"muted vote-compare-scope\" {\n-                    \"ranking children of \"\n-                    a href=(item_href(&parent)) { (child_title(&tree, &parent)) }\n-                }\n-                div class=\"vote-compare-pair\" {\n-                    (vote_compare_item_card(&tree, &left, \"vote-compare-left\"))\n-                    span class=\"vote-compare-vs\" { \"vs\" }\n-                    (vote_compare_item_card(&tree, &right, \"vote-compare-right\"))\n-                }\n-                (vote_back_nav(&parent))\n-                form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n-                    input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n-                    input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"1\";\n-                    input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"1\";\n-                    div class=\"vote-ratio-readout\" {\n-                        span class=\"muted small\" { \"ratio \" }\n-                        strong id=\"vote-ratio-display\" { \"1:1\" }\n+        div class=\"scope-theme vote-page\" style=(scope_theme_style(&parent)) {\n+            div class=\"vote-page-grid\" {\n+                section class=\"vote-compare-shell\" {\n+                    h1 { \"compare\" }\n+                    (breadcrumb_path(&parent))\n+                    p class=\"muted vote-compare-scope\" {\n+                        \"ranking children of \"\n+                        a href=(item_href(&parent)) { (child_title(&tree, &parent)) }\n                     }\n-                    label class=\"vote-compare-slider-label\" {\n-                        span id=\"vote-slider-left-label\" { (child_title(&tree, &left)) }\n-                        input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n-                            aria-valuemin=\"0\" aria-valuemax=\"100\";\n-                        span id=\"vote-slider-right-label\" { (child_title(&tree, &right)) }\n+                    div class=\"vote-compare-pair\" {\n+                        (vote_compare_item_card(&tree, &left, \"vote-compare-left\"))\n+                        span class=\"vote-compare-vs\" { \"vs\" }\n+                        (vote_compare_item_card(&tree, &right, \"vote-compare-right\"))\n+                    }\n+                    (vote_back_nav(&parent))\n+                    div id=\"vote-edge-history-region\" {\n+                        (edge_history)\n                     }\n-                    (vote_compare_actions(&parent, next_pair.as_ref()))\n-                }\n-                div id=\"vote-edge-history-region\" {\n-                    (edge_history)\n                 }\n+                (vote_ranking_sidebar(&tree, &parent, &left, &right))\n             }\n-            (vote_ranking_sidebar(&tree, &parent, &left, &right))\n+            (vote_hud_form(&parent, &left, &right, &rpc_json, next_pair.as_ref()))\n         }\n     };\n \ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 7d88114ff3c3d19011c003cf89e5fc32dd23ee74..50e34484c2b50d2eeb18f8d0ec4f34338363811a 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -287,6 +287,11 @@ h1 {\n   }\n }\n \n+.vote-page {\n+  --vote-hud-height: 4.75rem;\n+  padding-bottom: calc(var(--vote-hud-height) + 0.75rem);\n+}\n+\n .vote-page-grid {\n   display: grid;\n   grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);\n@@ -294,6 +299,50 @@ h1 {\n   align-items: start;\n }\n \n+.vote-hud {\n+  position: fixed;\n+  left: 0;\n+  right: 0;\n+  bottom: 0;\n+  z-index: 50;\n+  border-top: 2px solid var(--border);\n+  background: color-mix(in oklch, var(--panel) 92%, var(--bg));\n+  box-shadow: 0 -0.35rem 1.25rem rgba(0, 0, 0, 0.4);\n+  padding: 0.45rem 0.75rem;\n+  backdrop-filter: blur(8px);\n+}\n+\n+.vote-hud-inner {\n+  max-width: 56rem;\n+  margin: 0 auto;\n+  display: grid;\n+  grid-template-columns: min-content minmax(0, 1fr) auto;\n+  gap: 0.5rem 0.65rem;\n+  align-items: center;\n+}\n+\n+.vote-hud #vote-compare-form {\n+  margin: 0;\n+}\n+\n+.vote-hud .vote-ratio-readout {\n+  margin: 0;\n+  text-align: center;\n+}\n+\n+.vote-hud .vote-compare-actions {\n+  margin-top: 0;\n+  justify-content: flex-end;\n+  gap: 0.5rem;\n+}\n+\n+.vote-hud .btn-primary,\n+.vote-hud .btn-secondary,\n+.vote-hud .vote-compare-next {\n+  padding: 0.35rem 0.65rem;\n+  font-size: 0.875rem;\n+}\n+\n .vote-compare-shell {\n   min-width: 0;\n }\n@@ -366,14 +415,14 @@ h1 {\n   cursor: default;\n }\n \n-.vote-ratio-readout {\n-  margin: 1rem 0 0.25rem;\n-  text-align: center;\n-}\n-\n-.vote-ratio-readout strong {\n+.vote-hud .vote-ratio-readout strong {\n   color: var(--accent);\n   font-variant-numeric: tabular-nums;\n+  font-size: 1.1rem;\n+}\n+\n+.vote-hud .vote-ratio-readout .small {\n+  font-size: 0.75rem;\n }\n \n .vote-compare-next {\n@@ -393,25 +442,69 @@ h1 {\n   text-decoration: underline;\n }\n \n-.vote-compare-slider-label {\n-  display: grid;\n-  grid-template-columns: 1fr auto 1fr;\n-  gap: 0.75rem;\n-  align-items: center;\n-  margin: 1.25rem 0;\n+.vote-hud-slider {\n+  display: block;\n+  margin: 0;\n+  min-width: 0;\n }\n \n-.vote-compare-slider-label input[type=\"range\"] {\n+.vote-hud-slider input[type=\"range\"] {\n+  -webkit-appearance: none;\n+  appearance: none;\n+  display: block;\n   width: 100%;\n-  min-width: 160px;\n+  height: 1.5rem;\n+  margin: 0;\n+  background: transparent;\n+  cursor: pointer;\n+}\n+\n+.vote-hud-slider input[type=\"range\"]::-webkit-slider-runnable-track {\n+  height: 0.65rem;\n+  border-radius: 0;\n+  border: 2px solid var(--border);\n+  background: linear-gradient(\n+    to right,\n+    var(--accent) 0%,\n+    var(--accent) var(--vote-slider-pct, 50%),\n+    color-mix(in oklch, var(--muted) 55%, var(--bg)) var(--vote-slider-pct, 50%),\n+    color-mix(in oklch, var(--muted) 55%, var(--bg)) 100%\n+  );\n+}\n+\n+.vote-hud-slider input[type=\"range\"]::-webkit-slider-thumb {\n+  -webkit-appearance: none;\n+  appearance: none;\n+  width: 1.1rem;\n+  height: 1.35rem;\n+  margin-top: -0.42rem;\n+  border: 2px solid var(--fg);\n+  border-radius: 0;\n+  background: var(--accent);\n+  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.35);\n+}\n+\n+.vote-hud-slider input[type=\"range\"]::-moz-range-track {\n+  height: 0.65rem;\n+  border-radius: 0;\n+  border: 2px solid var(--border);\n+  background: color-mix(in oklch, var(--muted) 55%, var(--bg));\n }\n \n-#vote-slider-left-label {\n-  text-align: right;\n+.vote-hud-slider input[type=\"range\"]::-moz-range-progress {\n+  height: 0.65rem;\n+  border-radius: 0;\n+  background: var(--accent);\n }\n \n-#vote-slider-right-label {\n-  text-align: left;\n+.vote-hud-slider input[type=\"range\"]::-moz-range-thumb {\n+  width: 1.1rem;\n+  height: 1.35rem;\n+  border: 2px solid var(--fg);\n+  border-radius: 0;\n+  background: var(--accent);\n+  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.35);\n+  cursor: pointer;\n }\n \n .vote-edge-history-title {\n@@ -459,4 +552,24 @@ h1 {\n     position: static;\n     max-height: none;\n   }\n+}\n+\n+@media (max-width: 640px) {\n+  .vote-page {\n+    --vote-hud-height: 7.5rem;\n+  }\n+\n+  .vote-hud-inner {\n+    grid-template-columns: 1fr;\n+    grid-template-rows: auto auto auto;\n+    gap: 0.35rem;\n+  }\n+\n+  .vote-hud .vote-compare-actions {\n+    justify-content: center;\n+  }\n+\n+  .vote-hud {\n+    padding-bottom: max(0.45rem, env(safe-area-inset-bottom));\n+  }\n }\n\\ No newline at end of file\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 4c19a053c0fa62c951e8356d35c028b6f0d0f538..c438851b792ad85f8b6106b7c3fad9ea945275f7 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -138,6 +138,8 @@\n     function update() {\n       var v = parseInt(slider.value, 10);\n       if (!Number.isFinite(v)) v = 50;\n+      slider.style.setProperty('--vote-slider-pct', v + '%');\n+      slider.setAttribute('aria-valuenow', String(v));\n       var left = Math.max(1, 100 - v);\n       var right = Math.max(1, v);\n       var divisor = gcd(left, right);\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 9d770cda9990eef86e46721a22221aa8c1f9571c..1929d90ec7a35f53592c622699640d0dfc9f763c 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -185,8 +185,10 @@ async fn vote_page_renders_live_ranking_sidebar() {\n     assert!(html.contains(\"scope-theme\"));\n     assert!(html.contains(\"--accent: oklch(\"));\n     assert!(html.contains(\"--bg: oklch(\"));\n+    assert!(html.contains(\"vote-hud\"));\n     assert!(html.contains(\"vote-ratio-display\"));\n     assert!(html.contains(\">1:1<\"));\n+    assert!(!html.contains(\"vote-slider-left-label\"));\n     assert!(html.contains(\"--rank-bg: oklch(\"));\n     assert!(html.contains(\"--rank-fg: #\"));\n     assert!(html.contains(\"data-rank-item=\\\"alpha\\\"\"));\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}