Commit A implements a substantial, coherent feature (server-rendered theme selection replacing client-only JS theme switching, with cookie persistence, proper redirect/session cookie propagation, and room-aware wire URL helpers with dedicated unit tests) touching many call sites correctly. Commit B is a smaller UX polish (vote counts on garden rows, HUD unpin-via-form instead of link) plus one doc tweak and a browser test update, which is useful but narrower in scope and lasting impact than A's structural change to theming and API URL handling.
constitution · epochs · watch · epoch 3
c_3f420a1f5aa1 (tommy-mor) vs c_3ff71f7eaeda (tommy-mor)
download prompt · raw event · cmp_70561bf52a6669
council reasoning
A lands lasting infrastructure: cookie/server-rendered themes (with login cookie re-issue and POST /theme) plus correct private-room wire URLs via item_path_for_api_in_room/forum_thread_web_url threaded through RPC and accompanied by tests. B is real but narrower UX polish—vote counts on garden compare icons and turning the pin HUD into an unpin POST—valuable with tests, yet not as foundational as A’s product-wide path and theme design.
Side A introduces substantial, project-wide functionality: persistent theme selection via validated cookies and a new `/theme` endpoint, propagates theme state through authentication so login no longer drops the theme, and correctly generates room-scoped URLs across RPC responses with dedicated helpers and tests. Side B is a focused UI refinement that adds vote counts to garden compare links, changes the pin HUD into an unpin action, and updates CSS/tests, but its impact is localized compared with A's cross-cutting infrastructure and correctness improvements.
Side A introduces substantial cross-cutting functionality: a full theme system (cookie handling, POST /theme, layout changes) and correct room-aware URL generation by replacing item_path_for_api with item_path_for_api_in_room throughout RPC and helpers. Side B is a focused UI improvement (vote counts, HUD unpin behavior) with a small helper and test, but far less impactful to core behavior.
Side A introduces a full server-backed theme system (new /theme route, cookie normalization, layout changes, and propagation through all HTML handlers) and adds room-aware URL handling across RPC via `item_path_for_api_in_room` and `forum_thread_web_url`, affecting many endpoints and responses. Side B mainly enhances the garden UI with vote counts and a HUD unpin form plus related CSS/tests, which is valuable UX polish but narrower in scope and architectural impact than A’s cross-cutting design changes.
sides
A — c_3f420a1f5aa1 (tommy-mor)
message
[2fe70b0e] themes
diff preview
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<AuthCallbackQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_auth_callback(
+ Query(q): Query<AuthCallbackQuery>,
+ State(state): State<AppState>,
+ 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<AuthCallbackQuery>, 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<String>,
}
-pub async fn get_choose_username(Query(q): Query<ChooseUsernameQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_choose_username(
+ Query(q): Query<ChooseUsernameQuery>,
+ State(state): State<AppState>,
+ 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<AppState>,
+ jar: CookieJar,
Form(form): Form<ChooseUsernameForm>,
) -> 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<AppState>, 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<String, String> {
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<ScopeRankChanges> {
fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap<String, Option<RankPosition>> {
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<usize>,
want_percent: bool,
+ room_wire: &str,
) -> Result<RankResponse, RpcErr> {
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 {
-
… preview truncated; 42,928 characters omittedB — c_3ff71f7eaeda (tommy-mor)
message
[30a67104] fixes
diff preview
diff --git a/agents.md b/agents.md
index a6a283716e09fcaba1fd690f4e877e0bbecda2c0..d9a924d2f77c444d9b112bbf37a480b963ace4f0 100644
--- a/agents.md
+++ b/agents.md
@@ -39,7 +39,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card) and **`#vote-edge-history-region`** (recomputed edge list). Uses **`RpcResult::PostOk`**’s **`post_id`** / **`post_index`** for the card. **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.
-- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** when **`layout`** passes garden metadata on **`body`**.
+- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
**Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate.
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 8a46b1e92d38f2c390f88d48750d95d11388cd73..121d9498e8cb93d4d001dc1bbce23d74fbb958f5 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -16,7 +16,6 @@ use crate::{
canonical_path::{canonicalize_item, canonicalize_tag},
form_template::template_json_compact,
html::{
- forum::ingest_entry_markup,
ui_action::UI_RPC_FIELD,
user_can_post_room,
JsBuilder,
@@ -111,6 +110,23 @@ fn votes_for_edge(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec<crate::
out
}
+/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).
+fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {
+ let (lo, hi) = canonical_edge_items(a, b);
+ let lo_s = lo.as_str();
+ let hi_s = hi.as_str();
+ content
+ .item_votes
+ .get(&lo)
+ .into_iter()
+ .flat_map(|q| q.iter())
+ .filter(|v| {
+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
+ })
+ .count()
+}
+
fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec<String> {
let set: HashSet<String> = content
.item_threads
@@ -288,6 +304,7 @@ fn child_row_pin_or_vote(
nav: &ThreadNav,
row_item: &ItemId,
pinned_room_and_item: Option<&(String, ItemId)>,
+ scope_content: &ContentState,
next_path: &str,
) -> maud::Markup {
let pin_matches_scope = pinned_room_and_item
@@ -315,7 +332,16 @@ fn child_row_pin_or_vote(
@if pi == row_item {
span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "📌" }
} @else {
- a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title="Vote vs pinned" aria-label="Vote" { "⚖" }
+ @let nv = edge_vote_count_for_pair(scope_content, pi, row_item);
+ @let tip = format!(
+ "Compare and vote — {nv} pairwise vote{} in this scope for pinned vs this row",
+ if nv == 1 { "" } else { "s" },
+ );
+ @let aria = format!("Vote; {} pairwise {}", nv, if nv == 1 { "vote" } else { "votes" });
+ a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title=(tip) aria-label=(aria) {
+ span class="ont-garden-vote-glyph" aria-hidden="true" { "⚖" }
+ span class="ont-garden-vote-count" { (format!("{}", nv)) }
+ }
}
} @else {
form method="POST" action="/ui" data-navigate="full" class="ont-pin-form ont-garden-pin-form" {
@@ -978,10 +1004,9 @@ async fn render_scope_view(
) -> axum::response::Response {
let scope = nav.scope();
let pin_ref = pinned_item_from_jar(&jar);
- let model = {
- let reduced = state.reduced.read().await;
- build_item_page_view_model(&reduced, &scope, browse.item())
- };
+ let reduced = state.reduced.read().await;
+ let model = build_item_page_view_model(&reduced, &scope, browse.item());
+ let scope_content = content_for_garden_view(&reduced, &scope);
let thread_href = |tag: &str| nav.thread_url(tag);
let external_empty_body = browse.is_external() && model.body.is_none();
let cli_path_arg = item_display_path(&model.item);
@@ -1108,7 +1133,7 @@ async fn render_scope_view(
@let item_url = item_href(r.item.as_str(), &nav);
@let score_str = format!("{:.3}", r.score);
li data-garden-item=(r.item.as_str()) {
- (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), &next_for_pin))
+ (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin))
a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } }
span class="ont-rank-score" { (score_str) }
}
@@ -1124,7 +1149,7 @@ async fn render_scope_view(
ul class="ont-group-list" {
@for name in &model.child_rankings.unranked_items {
li data-garden-item=(name.as_str()) {
- (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), &next_for_pin))
+ (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin))
@let href = item_href(name.as_str(), &nav);
a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } }
}
@@ -1363,6 +1388,33 @@ mod tests {
}));
}
+ #[test]
+ fn edge_vote_count_for_pair_matches_votes_for_edge_len() {
+ use super::{
+ content_for_garden_view, edge_vote_count_for_pair, votes_for_edge,
+ };
+ use crate::path_types::ItemId;
+ let mut reduced = ReducerState::default();
+ apply_ingest(
+ &mut reduced,
+ 1,
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n\
+ ~/topic {root}\n\
+ ~/topic/a {alpha}\n\
+ ~/topic/b {beta}\n\
+ ~/topic/a 3:2 ~/topic/b {first vote}\n\
+ ~/topic/b 2:3 ~/topic/a {second vote}\n",
+ );
+ let content = content_for_garden_view(&reduced, &ScopeId::Public);
+ let a = ItemId::parse("~/topic/a").unwrap().normalized_storage();
+ let b = ItemId::parse("~/topic/b").unwrap().normalized_storage();
+ assert_eq!(
+ edge_vote_count_for_pair(content, &a, &b),
+ votes_for_edge(content, &a, &b).len()
+ );
+ assert_eq!(votes_for_edge(content, &a, &b).len(), 2);
+ }
+
#[test]
fn item_page_model_includes_body_and_unranked_without_votes() {
let mut reduced = ReducerState::default();
diff --git a/server/static/slug_ui.js b/server/static/slug_ui.js
index c0de1cddbba78227dfb80bfd41e7b855b3a42bc3..86f935f8dd998f3d6df016f33b1e47f9780782ea 100644
--- a/server/static/slug_ui.js
+++ b/server/static/slug_ui.js
@@ -170,15 +170,6 @@
return { room: raw.slice(0, i), item: raw.slice(i + 1) };
}
- function gardenItemHref(prefix, storageUrl) {
- var marker = 'https://slug.social/~/';
- if (storageUrl.indexOf(marker) === 0) {
- var tail = storageUrl.slice(marker.length);
- return prefix.replace(/\/$/, '') + (tail ? '/' + tail : '');
- }
- return storageUrl;
- }
-
function refreshPinHud() {
var hud = document.getElementById('slug-pin-hud');
if (!hud) return;
@@ -187,19 +178,37 @@
var pin = decodePinCookie();
hud.innerHTML = '';
if (!pin || !prefix || pin.room !== bodyRoom) return;
- var a = document.createElement('a');
- a.className = 'slug-pin-hud-link';
- a.href = gardenItemHref(prefix, pin.item);
- a.title = 'Pinned item';
+ var form = document.createElement('form');
+ form.method = 'POST';
+ form.action = '/ui';
+ form.setAttribute('data-navigate', 'full');
+ form.className = 'slug-pin-hud-form';
+ var rpc = document.createElement('input');
+ rpc.type = 'hidden';
+ rpc.name = '__rpc__';
+ rpc.value = JSON.stringify({
+ action: 'set_garden_pin',
+ clear: true,
+ room_wire: '',
+ next: window.location.pathname + window.location.search,
+ form_action: '/ui',
+ });
+ form.appendChild(rpc);
+ var btn = document.createElement('button');
+ btn.type = 'submit';
+ btn.className = 'slug-pin-hud-link slug-pin-hud-unpin-btn';
+ btn.title = 'Unpin — removes this item from the corner HUD';
+ btn.setAttribute('aria-label', 'Unpin pinned item');
var span = document.createElement('span');
span.className = 'slug-pin-hud-glyph';
span.setAttribute('aria-hidden', 'true');
span.textContent = '📌';
- a.appendChild(span);
+ btn.appendChild(span);
var label = pin.item.replace(/^https:\/\/slug\.social\/~\/?/, '~/');
if (label.length > 36) label = label.slice(0, 34) + '…';
- a.appendChild(document.createTextNode(' ' + label));
- hud.appendChild(a);
+ btn.appendChild(document.createTextNode(' ' + label));
+ form.appendChild(btn);
+ hud.appendChild(form);
}
refreshPinHud();
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index ec0fbe7acee0aa2802f978f14a9b0fc86e78c5b8..9178f0629cfb868348740e6dea1626dc6afb8345 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -799,6 +799,12 @@ details > summary::-webkit-details-marker { display: none; }
}
/* Pinned item HUD — bottom bar, same plane as spread */
+.slug-pin-hud-form {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ border: none;
+}
#slug-pin-hud.slug-pin-hud {
margin-left: auto;
max-width: min(42vw, 280px);
@@ -808,6 +814,13 @@ details > summary::-webkit-details-marker { display: none; }
overflow: hidden;
text-overflow: ellipsis;
}
+.slug-pin-hud-link.slug-pin-hud-unpin-btn {
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font-size: inherit;
+ font-family: inherit;
+}
.slug-pin-hud-link {
color: var(--ui);
text-decoration: none;
@@ -815,7 +828,10 @@ details > summary::-webkit-details-marker { display: none; }
align-items: center;
gap: 4px;
}
-.slug-pin-hud-link:hover { color: var(--signal); }
+.slug-pin-hud-link:hover,
+.slug-pin-hud-unpin-btn:hover {
+ color: var(--signal);
+}
.slug-pin-hud-glyph { font-size: 13px; line-height: 1; }
/* Garden pin / vote controls */
@@ -890,10 +906,21 @@ span.ont-garden-pinned-here {
align-items: center;
justify-content: center;
}
+a.ont-garden-vote-ico {
+ gap: 4px;
+}
a.ont-garden-vote-ico:hover {
color: var(--signal);
background: var(--g4);
}
+.ont-garden-v
… preview truncated; 4,237 characters omittedHardlinks — judgments / attempts / prompt
judgments
- ~anthropic/claude-sonnet-latest: A (6:4)
- ~x-ai/grok-latest: A (3:1)
- openai/gpt-chat-latest: A (5:1)
- openai/gpt-5.3-chat: A (4:1)
- openai/gpt-5.2-chat: A (3:1)
attempts
- ~anthropic/claude-sonnet-latest #1
- ~x-ai/grok-latest #1
- openai/gpt-chat-latest #1
- openai/gpt-5.3-chat #1
- openai/gpt-5.2-chat #1
Prompt text is loaded only by the download route.