Side B is a focused, coherent UI fix: it pins vote controls into a bottom HUD, updates CSS/JS accordingly, and adds/updates tests (mod.rs, integration_ui.rs) to verify the new markup and behavior. Side A is a raw dump of a full 929-line mod.rs file with no diff context (appears to be a whole-file add rather than an incremental change), making it hard to assess as a genuine, reviewable commit, and it lacks any test coverage tied to the change itself.
constitution · epochs · watch · epoch 3
c_35dc6601c89d (tommy-mor) vs c_254ba025ef2e (tommy-mor)
download prompt · raw event · cmp_ce09e7673a186e
council reasoning
A introduces lasting infrastructure: a reusable JsBuilder/JsQueryBuilder for server-driven morph/redirect scripts plus core layout, theming, linkify, and embed rendering with tests. B is a focused, polished vote-compare HUD (fixed bar, CSS/JS slider state, tests) but only rearranges existing controls and does not match that foundational scope.
Side A introduces a substantial HTML/UI infrastructure module with reusable functionality: a `JsBuilder` abstraction for generating UI responses, theme handling and cookie persistence, static asset serving, shared layout functions, linkification with garden-aware routing, rich media embedding, breadcrumb utilities, and accompanying tests. Side B is a focused UX improvement that reorganizes vote controls into a fixed bottom HUD, updates CSS/JS for the slider, and adds regression tests, but it builds on existing infrastructure rather than creating broadly reusable project capabilities.
sides
A — c_35dc6601c89d (tommy-mor)
message
[deb79714] jsbuilder ofc
diff preview
diff --git a/mod.rs b/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b1dfe895515a1ba94304c52ac4ea82ff6bb84c16
--- /dev/null
+++ b/mod.rs
@@ -0,0 +1,929 @@
+use axum::{
+ body::Body,
+ extract::Path,
+ 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::{HashMap, HashSet};
+
+mod auth;
+mod breadcrumb_path;
+mod editor;
+mod forum;
+mod garden;
+pub mod routing;
+mod search;
+pub mod ui_action;
+use breadcrumb_path::{ExternalOntologyPath, OntologyPath};
+
+pub use auth::{
+ auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
+ choose_username_page,
+};
+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_feed_region_markup, thread_post_view, thread_view, ThreadNav,
+};
+
+pub use forum::user_profile_page;
+pub(crate) use forum::{
+ fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,
+ thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post,
+ user_can_post_room, user_can_view_room,
+};
+pub(crate) use garden::{
+ encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,
+ GARDEN_PIN_COOKIE,
+};
+pub use garden::{
+ external_garden_index, external_ontology_path, garden_index, ontology_path,
+ room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,
+ room_vote_compare_page, vote_compare_page,
+};
+pub use routing::RouteContext;
+pub use search::{search_page, search_results_fragment};
+pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};
+
+/// Public profile URL path for a stored username (no `@`).
+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<HeaderValue> {
+ 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<String>,
+}
+
+/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch).
+pub async fn post_theme(Form(form): Form<ThemeForm>) -> 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 and shared UI script 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");
+const THEME_RETRO_CRAFT_CSS: &str = include_str!("../../static/theme_retro_craft.css");
+const SLUG_UI_JS: &str = include_str!("../../static/slug_ui.js");
+
+pub async fn serve_static(Path(filename): Path<String>) -> impl IntoResponse {
+ if filename == "slug_ui.js" {
+ return Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+ .header(header::CACHE_CONTROL, "public, max-age=3600")
+ .body(SLUG_UI_JS.to_string())
+ .unwrap()
+ .into_response();
+ }
+
+ let theme = filename
+ .strip_prefix("theme_")
+ .and_then(|s| s.strip_suffix(".css"));
+
+ let css = match theme {
+ Some("default") => THEME_DEFAULT_CSS,
+ Some("retro") => THEME_RETRO_CSS,
+ Some("retro_craft") => THEME_RETRO_CRAFT_CSS,
+ _ => return (StatusCode::NOT_FOUND, "static file not found").into_response(),
+ };
+
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
+ .header(header::CACHE_CONTROL, "public, max-age=3600")
+ .body(css.to_string())
+ .unwrap()
+ .into_response()
+}
+
+pub(crate) fn js_string_literal(s: &str) -> String {
+ serde_json::to_string(s).expect("javascript string escaping")
+}
+
+/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures.
+pub(crate) fn ui_js_warn(msg: &str) -> Response {
+ let js = format!("console.warn({});", js_string_literal(msg));
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+ .body(Body::from(js))
+ .unwrap()
+}
+
+pub(crate) struct JsBuilder {
+ snippets: Vec<String>,
+}
+
+pub(crate) struct JsQueryBuilder {
+ builder: JsBuilder,
+ expr: String,
+}
+
+impl JsBuilder {
+ pub(crate) fn new() -> Self {
+ Self {
+ snippets: Vec::new(),
+ }
+ }
+
+ pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self {
+ self.morph_expr(
+ &format!("document.querySelector({})", js_string_literal(selector)),
+ markup,
+ None,
+ )
+ }
+
+ /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`.
+ pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self {
+ self.qs(selector).morph_inner(markup)
+ }
+
+ pub(crate) fn morph_expr(
+ mut self,
+ expr: &str,
+ markup: Markup,
+ morph_style: Option<&str>,
+ ) -> Self {
+ let html = js_string_literal(&markup.into_string());
+ let opts = morph_style
+ .map(|style| format!(", {{morphStyle: {}}}", js_string_literal(style)))
+ .unwrap_or_default();
+ self.snippets.push(format!(
+ "var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}",
+ ));
+ self
+ }
+
+ pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder {
+ JsQueryBuilder {
+ builder: self,
+ expr: format!("document.querySelector({})", js_string_literal(selector)),
+ }
+ }
+
+ pub(crate) fn id(self, id: &str) -> JsQueryBuilder {
+ self.qs(&format!("#{id}"))
+ }
+
+ pub(crate) fn if_current_path_matches(
+ mut self,
+ path: &str,
+ f: impl FnOnce(JsBuilder) -> JsBuilder,
+ ) -> Self {
+ let inner = f(JsBuilder::new()).build();
+ self.snippets.push(format!(
+ "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}",
+ path = js_string_literal(path),
+ ));
+ self
+ }
+
+ pub(crate) fn if_current_path_not_matches(
+ mut self,
+ path: &str,
+ f: impl FnOnce(JsBuilder) -> JsBuilder,
+ ) -> Self {
+ let inner = f(JsBuilder::new()).build();
+ self.snippets.push(format!(
+ "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}",
+ path = js_string_literal(path),
+ ));
+ self
+ }
+
+ pub(crate) fn redirect(mut self, to: &str) -> Self {
+ self.snippets
+ .push(format!("window.location = {};", js_string_literal(to)));
+ self
+ }
+
+ pub(crate) fn clipboard_write_text_and_label_btn(
+ mut self,
+ text: &str,
+ btn_id: &str,
+ copied_label: &str,
+ ) -> Self {
+ self.snippets.push(format!(
+ "navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});",
+ text = js_string_literal(text),
+ btn_id = js_string_literal(btn_id),
+ label = js_string_literal(copied_label),
+ ));
+ self
+ }
+
+ /// Focus first matching element (e.g. after morphing open a compose form).
+ pub(crate) fn focus_selector(mut self, selector: &str) -> Self {
+ self.snippets.push(format!(
+ "var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}",
+ js_string_literal(selector),
+ ));
+ self
+ }
+
+ pub(crate) fn build(self) -> String {
+ self.snippets.join(" ")
+ }
+
+ pub(crate) fn into_response(self) -> Response {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+ .body(Body::from(self.build()))
+ .unwrap()
+ }
+}
+
+impl JsQueryBuilder {
+ pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder {
+ let html = js_string_literal(&markup.into_string());
+ self.builder.snippets.push(format!(
+ "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}",
+ expr = self.expr,
+ ));
+ self.builder
+ }
+
+ pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder {
+ let html = js_string_literal(&markup.into_string());
+ self.builder.snippets.push(format!(
+ "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}",
+ expr = self.expr,
+ ));
+ self.builder
+ }
+
+ pub(crate) fn reset(mut self) -> JsBuilder {
+ self.builder.snippets.push(format!(
+ "var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}",
+ expr = self.expr,
+ ));
+ self.builder
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(super) fn layout(
+ title: &str,
+ view: &str,
+ body: Markup,
+ views: Option<u64>,
+ theme: &str,
+ theme_nex
… preview truncated; 20,888 characters omittedB — c_254ba025ef2e (tommy-mor)
message
[df769734] Pin vote controls in a compact bottom HUD on compare pages. Keeps ratio, slider, and actions always visible without repeating item titles beside the slider. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 1822528e3e3b526059899349ba2f73ae0666f944..dcf05df143825f4f8ba9ee3b3b5f8ab2e69950c7 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -528,5 +528,6 @@ mod tests {
assert!(SORTER_UI_JS.contains("Math.max(1, v)"));
assert!(SORTER_UI_JS.contains("var divisor = gcd(left, right)"));
assert!(SORTER_UI_JS.contains("ratioDisplay.textContent = left + ':' + right"));
+ assert!(SORTER_UI_JS.contains("--vote-slider-pct"));
}
}
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
index 7be4be152ea331fb7afb7ce598aed27d23e35cc4..872d23ddf6c18fab016a624de85736fdc2dcde63 100644
--- a/server/src/html/vote.rs
+++ b/server/src/html/vote.rs
@@ -134,6 +134,36 @@ fn vote_back_nav(parent: &ItemId) -> Markup {
}
}
+fn vote_hud_form(
+ parent: &ItemId,
+ left: &ItemId,
+ right: &ItemId,
+ rpc_json: &str,
+ next_pair: Option<&(ItemId, ItemId)>,
+) -> Markup {
+ html! {
+ div id="vote-hud" class="vote-hud" role="region" aria-label="Vote controls" {
+ form id="vote-compare-form" method="POST" action="/ui" {
+ input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
+ input type="hidden" name="ratio_left" id="vote-ratio-left" value="1";
+ input type="hidden" name="ratio_right" id="vote-ratio-right" value="1";
+ div class="vote-hud-inner" {
+ div class="vote-ratio-readout" {
+ span class="muted small" { "ratio " }
+ strong id="vote-ratio-display" { "1:1" }
+ }
+ label class="vote-hud-slider" {
+ input type="range" id="vote-preference-slider" min="0" max="100" value="50"
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"
+ aria-label=(format!("Preference: {} vs {}", left.as_str(), right.as_str()));
+ }
+ (vote_compare_actions(parent, next_pair))
+ }
+ }
+ }
+ }
+}
+
fn vote_compare_actions(parent: &ItemId, next: Option<&(ItemId, ItemId)>) -> Markup {
let next_href = next.map(|(l, r)| vote_compare_href(parent, l, r));
html! {
@@ -250,41 +280,28 @@ pub async fn vote_page(
);
let body = html! {
- div class="scope-theme vote-page-grid" style=(scope_theme_style(&parent)) {
- section class="vote-compare-shell" {
- h1 { "compare" }
- (breadcrumb_path(&parent))
- p class="muted vote-compare-scope" {
- "ranking children of "
- a href=(item_href(&parent)) { (child_title(&tree, &parent)) }
- }
- div class="vote-compare-pair" {
- (vote_compare_item_card(&tree, &left, "vote-compare-left"))
- span class="vote-compare-vs" { "vs" }
- (vote_compare_item_card(&tree, &right, "vote-compare-right"))
- }
- (vote_back_nav(&parent))
- form id="vote-compare-form" method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
- input type="hidden" name="ratio_left" id="vote-ratio-left" value="1";
- input type="hidden" name="ratio_right" id="vote-ratio-right" value="1";
- div class="vote-ratio-readout" {
- span class="muted small" { "ratio " }
- strong id="vote-ratio-display" { "1:1" }
+ div class="scope-theme vote-page" style=(scope_theme_style(&parent)) {
+ div class="vote-page-grid" {
+ section class="vote-compare-shell" {
+ h1 { "compare" }
+ (breadcrumb_path(&parent))
+ p class="muted vote-compare-scope" {
+ "ranking children of "
+ a href=(item_href(&parent)) { (child_title(&tree, &parent)) }
}
- label class="vote-compare-slider-label" {
- span id="vote-slider-left-label" { (child_title(&tree, &left)) }
- input type="range" id="vote-preference-slider" min="0" max="100" value="50"
- aria-valuemin="0" aria-valuemax="100";
- span id="vote-slider-right-label" { (child_title(&tree, &right)) }
+ div class="vote-compare-pair" {
+ (vote_compare_item_card(&tree, &left, "vote-compare-left"))
+ span class="vote-compare-vs" { "vs" }
+ (vote_compare_item_card(&tree, &right, "vote-compare-right"))
+ }
+ (vote_back_nav(&parent))
+ div id="vote-edge-history-region" {
+ (edge_history)
}
- (vote_compare_actions(&parent, next_pair.as_ref()))
- }
- div id="vote-edge-history-region" {
- (edge_history)
}
+ (vote_ranking_sidebar(&tree, &parent, &left, &right))
}
- (vote_ranking_sidebar(&tree, &parent, &left, &right))
+ (vote_hud_form(&parent, &left, &right, &rpc_json, next_pair.as_ref()))
}
};
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 7d88114ff3c3d19011c003cf89e5fc32dd23ee74..50e34484c2b50d2eeb18f8d0ec4f34338363811a 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -287,6 +287,11 @@ h1 {
}
}
+.vote-page {
+ --vote-hud-height: 4.75rem;
+ padding-bottom: calc(var(--vote-hud-height) + 0.75rem);
+}
+
.vote-page-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
@@ -294,6 +299,50 @@ h1 {
align-items: start;
}
+.vote-hud {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 50;
+ border-top: 2px solid var(--border);
+ background: color-mix(in oklch, var(--panel) 92%, var(--bg));
+ box-shadow: 0 -0.35rem 1.25rem rgba(0, 0, 0, 0.4);
+ padding: 0.45rem 0.75rem;
+ backdrop-filter: blur(8px);
+}
+
+.vote-hud-inner {
+ max-width: 56rem;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: min-content minmax(0, 1fr) auto;
+ gap: 0.5rem 0.65rem;
+ align-items: center;
+}
+
+.vote-hud #vote-compare-form {
+ margin: 0;
+}
+
+.vote-hud .vote-ratio-readout {
+ margin: 0;
+ text-align: center;
+}
+
+.vote-hud .vote-compare-actions {
+ margin-top: 0;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+.vote-hud .btn-primary,
+.vote-hud .btn-secondary,
+.vote-hud .vote-compare-next {
+ padding: 0.35rem 0.65rem;
+ font-size: 0.875rem;
+}
+
.vote-compare-shell {
min-width: 0;
}
@@ -366,14 +415,14 @@ h1 {
cursor: default;
}
-.vote-ratio-readout {
- margin: 1rem 0 0.25rem;
- text-align: center;
-}
-
-.vote-ratio-readout strong {
+.vote-hud .vote-ratio-readout strong {
color: var(--accent);
font-variant-numeric: tabular-nums;
+ font-size: 1.1rem;
+}
+
+.vote-hud .vote-ratio-readout .small {
+ font-size: 0.75rem;
}
.vote-compare-next {
@@ -393,25 +442,69 @@ h1 {
text-decoration: underline;
}
-.vote-compare-slider-label {
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- gap: 0.75rem;
- align-items: center;
- margin: 1.25rem 0;
+.vote-hud-slider {
+ display: block;
+ margin: 0;
+ min-width: 0;
}
-.vote-compare-slider-label input[type="range"] {
+.vote-hud-slider input[type="range"] {
+ -webkit-appearance: none;
+ appearance: none;
+ display: block;
width: 100%;
- min-width: 160px;
+ height: 1.5rem;
+ margin: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.vote-hud-slider input[type="range"]::-webkit-slider-runnable-track {
+ height: 0.65rem;
+ border-radius: 0;
+ border: 2px solid var(--border);
+ background: linear-gradient(
+ to right,
+ var(--accent) 0%,
+ var(--accent) var(--vote-slider-pct, 50%),
+ color-mix(in oklch, var(--muted) 55%, var(--bg)) var(--vote-slider-pct, 50%),
+ color-mix(in oklch, var(--muted) 55%, var(--bg)) 100%
+ );
+}
+
+.vote-hud-slider input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 1.1rem;
+ height: 1.35rem;
+ margin-top: -0.42rem;
+ border: 2px solid var(--fg);
+ border-radius: 0;
+ background: var(--accent);
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.35);
+}
+
+.vote-hud-slider input[type="range"]::-moz-range-track {
+ height: 0.65rem;
+ border-radius: 0;
+ border: 2px solid var(--border);
+ background: color-mix(in oklch, var(--muted) 55%, var(--bg));
}
-#vote-slider-left-label {
- text-align: right;
+.vote-hud-slider input[type="range"]::-moz-range-progress {
+ height: 0.65rem;
+ border-radius: 0;
+ background: var(--accent);
}
-#vote-slider-right-label {
- text-align: left;
+.vote-hud-slider input[type="range"]::-moz-range-thumb {
+ width: 1.1rem;
+ height: 1.35rem;
+ border: 2px solid var(--fg);
+ border-radius: 0;
+ background: var(--accent);
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.35);
+ cursor: pointer;
}
.vote-edge-history-title {
@@ -459,4 +552,24 @@ h1 {
position: static;
max-height: none;
}
+}
+
+@media (max-width: 640px) {
+ .vote-page {
+ --vote-hud-height: 7.5rem;
+ }
+
+ .vote-hud-inner {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto;
+ gap: 0.35rem;
+ }
+
+ .vote-hud .vote-compare-actions {
+ justify-content: center;
+ }
+
+ .vote-hud {
+ padding-bottom: max(0.45rem, env(safe-area-inset-bottom));
+ }
}
\ No newline at end of file
diff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js
index 4c19a053c0fa62c951e8356d35c028b6f0d0f538..c438851b792ad85f8b6106b7c3fad9ea945275f7 100644
--- a/server/static/sorter_ui.js
+++ b/server/static/sorter_ui.js
@@ -138,6 +138,8 @@
function update() {
var v = parseInt(slider.value, 10);
if (!Number.isFinite(v)) v = 50;
+ slider.style.setProperty('--vote-slider-pct', v + '%');
+ slider.setAttribute('aria-valuenow', String(v));
var left = Math.max(1, 100 - v);
var right = Math.max(1, v);
var divisor = gcd(left, right);
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 9d770cda9990eef86e46721a22221aa8c1f9571c..1929d90ec7a35f53592c622699640d0dfc9f763c 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -185,8 +185,10 @@ async fn vote_page_renders_live_ranking_sidebar() {
assert!(html.contains("scope-theme"));
assert!(html.contains("--accent: oklch("));
assert!(html.contains("--bg: oklch("));
+ assert!(html.contains("vote-hud"));
assert!(html.contains("vote-ratio-display"));
assert!(html.contains(">1:1<"));
+ assert!(!html.contains("vote-slider-left-label"));
assert!(html.contains("--rank-bg: oklch("));
assert!(html.contains("--rank-fg: #"));
assert!(html.contains("data-rank-item=\"alpha\""));
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.