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: [02cd761c] Make ranked child group pin icons clickable to unpin. Lets users clear the garden pin from the ranked child groups list via the same POST /ui set_garden_pin flow as the HUD and item header. Co-authored-by: Cursor Side A โ€” unified diff (full patch): diff --git a/server/src/html/garden/pin.rs b/server/src/html/garden/pin.rs index 1820d7fe7ee167ecbf5ad5124f23b2ab92ffb01f..b7ea92bbe67d6d499915edb607a3a0b36178d7f6 100644 --- a/server/src/html/garden/pin.rs +++ b/server/src/html/garden/pin.rs @@ -101,6 +101,7 @@ pub(super) fn child_row_pin_or_vote( 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 .map(|(r, _)| r == nav.room_wire.as_str()) @@ -108,12 +109,25 @@ pub(super) fn child_row_pin_or_vote( let pinned_item = pinned_room_and_item .filter(|_| pin_matches_scope) .map(|(_, i)| i); + let unpin_rpc = template_json_compact(&json!({ + "action": "set_garden_pin", + "clear": true, + "room_wire": "", + "next": next_path, + "form_action": "/ui", + })) + .expect("unpin rpc json"); html! { @if let Some(pi) = pinned_item { span class="ont-garden-child-actions" data-garden-room=(nav.room_wire.as_str()) { @if pi == row_item { - span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "๐Ÿ“Œ" } + form method="POST" action="/ui" data-navigate="full" class="ont-garden-pin-form" { + input type="hidden" name=(UI_RPC_FIELD) value=(unpin_rpc); + button type="submit" class="ont-garden-pin-ico ont-garden-pin-ico-active" title="Unpin" aria-label="Unpin from HUD" { + span class="ont-garden-pin-glyph" aria-hidden="true" { "๐Ÿ“Œ" } + } + } } @else { @let nv = edge_vote_count_for_pair(scope_content, pi, row_item); @let tip = format!( diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs index bd8cbce059ee789de6e8dc9b6f69f54beee2f994..7bfaadc0786b734db8973c53b82296a188d26e22 100644 --- a/server/src/html/garden/render.rs +++ b/server/src/html/garden/render.rs @@ -216,7 +216,7 @@ pub(super) 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(), scope_content)) + (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) } } @@ -232,7 +232,7 @@ pub(super) 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(), scope_content)) + (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())) } } } diff --git a/server/static/theme_default.css b/server/static/theme_default.css index 2dabf3a7094cecd89d0c9674d27efe30cd01c010..b846ab86cb6eb5fb56be87efb054fb8b18082583 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -891,8 +891,7 @@ button.ont-pin-btn-active { vertical-align: middle; } button.ont-garden-pin-ico, -a.ont-garden-vote-ico, -span.ont-garden-pinned-here { +a.ont-garden-vote-ico { font-size: 13px; line-height: 1; padding: 2px 6px; @@ -910,6 +909,11 @@ span.ont-garden-pinned-here { align-items: center; justify-content: center; } +form.ont-garden-pin-form { + display: inline; + margin: 0; + padding: 0; +} a.ont-garden-vote-ico { gap: 4px; } @@ -925,10 +929,14 @@ a.ont-garden-vote-ico:hover { span.ont-garden-vote-glyph { line-height: 1; } -span.ont-garden-pinned-here { +button.ont-garden-pin-ico-active { border-color: var(--link); color: var(--signal); - cursor: default; + background: color-mix(in srgb, var(--link) 12%, var(--g3)); +} +button.ont-garden-pin-ico-active:hover { + color: var(--signal); + background: color-mix(in srgb, var(--link) 18%, var(--g4)); } button.ont-garden-pin-ico:focus-visible { outline: 2px solid var(--link); diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index bdd9031b82154a5019782e6dcb8bf7589fafce09..5d4954812ad43f2ee7e63c71c7f0ae0b3e3fc95b 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -711,8 +711,7 @@ body.view-ontology span.ont-garden-vote-glyph { line-height: 1; } body.view-ontology button.ont-garden-pin-ico, -body.view-ontology a.ont-garden-vote-ico, -body.view-ontology span.ont-garden-pinned-here { +body.view-ontology a.ont-garden-vote-ico { font-size: 0.95rem; line-height: 1; padding: 0.1rem 0.35rem; @@ -732,10 +731,14 @@ body.view-ontology a.ont-garden-vote-ico:hover { border-color: #a68e6b; background: #ebe6dc; } -body.view-ontology span.ont-garden-pinned-here { +body.view-ontology button.ont-garden-pin-ico-active { border-color: #1a4a8c; background: color-mix(in srgb, #1a4a8c 10%, #f7f3eb); - cursor: default; + color: #0d2d5c; +} +body.view-ontology button.ont-garden-pin-ico-active:hover { + border-color: #a68e6b; + background: color-mix(in srgb, #1a4a8c 14%, #ebe6dc); } body.view-ontology form.ont-garden-pin-form { display: inline; diff --git a/test/browser_garden_pin.clj b/test/browser_garden_pin.clj index d4fa9ed60b45e872ec60e29307cfa61bcd9dd6b2..579976f4c2493a149f9eba081862ca07919f4fe9 100644 --- a/test/browser_garden_pin.clj +++ b/test/browser_garden_pin.clj @@ -58,10 +58,11 @@ (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice") thread-tag "browser-garden-pin" raw (str "# " thread-tag "\n\n" - "~/gp-pin-a {alpha}\n" - "~/gp-pin-b {beta}\n" + "~/gp-parent {parent}\n" + "~/gp-parent/pin-a {alpha}\n" + "~/gp-parent/pin-b {beta}\n" "{pin test vote}\n" - "~/gp-pin-a 2:1 ~/gp-pin-b\n") + "~/gp-parent/pin-a 2:1 ~/gp-parent/pin-b\n") post-resp (oauth/http-post-json (str base-url "/api/v0/rpc") [{"Post" {"room" "public" @@ -77,23 +78,43 @@ (core/with-page [pg (core/new-page-from-context ctx)] (page/navigate pg (str base-url "/login")) (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") - (page/navigate pg (str base-url "/~/gp-pin-a")) - (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-a" 15000) "on item a page") + (page/navigate pg (str base-url "/~/gp-parent/pin-a")) + (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-a" 15000) "on item a page") ;; Native form POST /ui (data-navigate=full) โ€” not fetch/eval (locator/click (page/locator pg ".ont-item-pin-zone form.ont-pin-form button[type=submit]")) - (is (wait-for-text pg "#slug-pin-hud" "gp-pin-a" 15000) + (is (wait-for-text pg "#slug-pin-hud" "gp-parent/pin-a" 15000) "HUD shows pinned item label after redirect") - (page/navigate pg (str base-url "/~/gp-pin-b")) - (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-b" 15000) "on item b page") + (page/navigate pg (str base-url "/~/gp-parent/pin-b")) + (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-b" 15000) "on item b page") (is (wait-for-text pg "a.ont-vote-compare-btn" "vote" 10000) "vote link visible vs pinned item") - ;; HUD clears pin (POST set_garden_pin clear), not navigate to item + ;; Unpin from ranked child groups on parent page + (page/navigate pg (str base-url "/~/gp-parent")) + (is (wait-for-text pg ".ont-tab-panel-children" "ranked child groups" 15000) + "parent page shows ranked child groups") + (is (wait-for-text pg ".ont-ranking-list button.ont-garden-pin-ico-active" "๐Ÿ“Œ" 10000) + "pinned row shows active pin in ranked list") + (locator/click (page/locator pg ".ont-ranking-list button.ont-garden-pin-ico-active")) + (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-parent/pin-a" 15000) + "HUD clears after unpin from ranked child list") + (is (wait-for-absence-substr pg ".ont-ranking-list" "ont-garden-vote-ico" 10000) + "vote icons removed from ranked list after unpin") + (page/navigate pg (str base-url "/~/gp-parent/pin-b")) + (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-b" 15000) "on item b page again") + (is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000) + "compare vote CTA removed after ranked-list unpin") + ;; HUD unpin still works from item page context + (page/navigate pg (str base-url "/~/gp-parent/pin-a")) + (locator/click (page/locator pg ".ont-item-pin-zone form.ont-pin-form button[type=submit]")) + (is (wait-for-text pg "#slug-pin-hud" "gp-parent/pin-a" 15000) + "re-pin from item page for HUD unpin test") + (page/navigate pg (str base-url "/~/gp-parent/pin-b")) (locator/click (page/locator pg "#slug-pin-hud button.slug-pin-hud-unpin-btn")) - (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-pin-a" 15000) + (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-parent/pin-a" 15000) "HUD clears after unpin from HUD button") (is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000) "compare vote CTA removed after HUD unpin on same reload") - (is (str/includes? (or (page/url pg) "") "/~/gp-pin-b") + (is (str/includes? (or (page/url pg) "") "/~/gp-parent/pin-b") "still on item b after HUD unpin")))))) (finally Side B โ€” contributor: tommy-mor Side B โ€” commit message: [6b6eb0c3] Auth form: poem JS morphs form innerHTML on response; no redirect - post_choose_username returns HTML fragments instead of redirects: success โ†’ auth_signed_in_fragment ("you're signed in โ€” return to your agent") error โ†’ choose_username_error_fragment (form re-rendered with error inline) - Poem JS now reads response body; if non-empty, morphs form innerHTML with it (existing ingest forms return empty body, so they're unaffected) - auth.rs: keep full layout() with poem JS โ€” revert to single layout - auth-success CSS class added to both themes Co-Authored-By: Claude Sonnet 4.6 (1M context) Side B โ€” unified diff (full patch): diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index c1194f79fd89fb47fe6b425b494a0ffa667abb2d..cb0faa29b834931e2c2b2f5c174c875e2e2e9346 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -16,7 +16,7 @@ use crate::{ canonicalize_username, validate_agent_format, validate_username, Event, TokenIssued, UserRegistered, }, - html::{auth_complete_page, choose_username_page}, + html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page}, state::{AppState, PendingSession}, }; @@ -274,8 +274,6 @@ pub async fn post_choose_username( return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response(); } - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); - let reduced_arc = state.reduced.clone(); let reduced = reduced_arc.read().await; let provider_key = (provider.to_lowercase(), provider_id.clone()); @@ -284,11 +282,7 @@ pub async fn post_choose_username( } if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) { drop(reduced); - return Redirect::to(&format!( - "{public_url}/auth/choose-username?session={}&error={}", - urlencoding::encode(&form.session), - urlencoding::encode("that username is taken โ€” try another"), - )).into_response(); + return choose_username_error_fragment(&form.session, "that username is taken โ€” try another").into_response(); } drop(reduced); @@ -324,7 +318,7 @@ pub async fn post_choose_username( s.complete = Some((canon_user.clone(), bearer.clone())); } - Redirect::to(&format!("{public_url}/auth/complete")).into_response() + auth_signed_in_fragment().into_response() } pub async fn post_pending_session( diff --git a/server/src/html/auth.rs b/server/src/html/auth.rs index 40b1ef30a6c1d4063aa2d8e9c93df8972df4b27c..0a14bdbfb66c65d1bd09993ffd4a7bb6f2fe041e 100644 --- a/server/src/html/auth.rs +++ b/server/src/html/auth.rs @@ -1,20 +1,25 @@ -use maud::{html, Markup, DOCTYPE}; +use maud::{html, Markup}; -/// Minimal layout for auth pages โ€” no JS interceptor, real form navigation works. -fn auth_layout(title: &str, body: Markup) -> Markup { +fn form_inner(session: &str, error: Option<&str>) -> Markup { html! { - (DOCTYPE) - html { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; - title { (title) } - link rel="stylesheet" href="/static/theme_default.css"; - } - body class="view-auth" { - (body) - } + input type="hidden" name="session" value=(session); + label for="username" { "username" } + input + type="text" + id="username" + name="username" + placeholder="e.g. alice" + pattern="[a-z0-9_\\-]{1,32}" + maxlength="32" + autocomplete="off" + autofocus; + p.auth-hint { + "lowercase ยท alphanumeric ยท hyphens ยท underscores ยท max 32" } + @if let Some(msg) = error { + p.auth-error { (msg) } + } + button type="submit" { "continue" } } } @@ -28,27 +33,23 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup { h1 { "choose a username" } p { "pick a handle for slug.social." } form.auth-form method="POST" action="/auth/choose-username" { - input type="hidden" name="session" value=(session); - label for="username" { "username" } - input - type="text" - id="username" - name="username" - placeholder="e.g. alice" - pattern="[a-z0-9_\\-]{1,32}" - maxlength="32" - autocomplete="off" - autofocus; - p.auth-hint { - "lowercase ยท alphanumeric ยท hyphens ยท underscores ยท max 32" - } - @if let Some(msg) = error { - p.auth-error { (msg) } - } - button type="submit" { "continue" } + (form_inner(session, error)) } }; - auth_layout("join โ€” slug.social", body) + super::layout("join โ€” slug.social", "view-auth", body, None) +} + +/// Fragment returned to the poem JS on error โ€” replaces the form's innerHTML. +pub fn choose_username_error_fragment(session: &str, error: &str) -> Markup { + form_inner(session, Some(error)) +} + +/// Fragment returned to the poem JS on success โ€” replaces the form's innerHTML. +pub fn auth_signed_in_fragment() -> Markup { + html! { + p.auth-success { "you're signed in โ€” return to your agent." } + p.auth-hint { "you can close this tab." } + } } pub fn auth_complete_page() -> Markup { @@ -62,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." } }; - auth_layout("signed in โ€” slug.social", body) + super::layout("signed in โ€” slug.social", "view-auth", body, None) } diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 2f16d701962d703db0c859bb586dfc08ec385690..8b48a25cce79f0eefc7e29849e1a667cae4c7986 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -15,7 +15,7 @@ mod search; mod tree; use breadcrumb_path::OntologyPath; -pub use auth::{auth_complete_page, choose_username_page}; +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::{index, thread_feed_html, thread_post_expand, thread_post_view, thread_view}; pub use garden::{garden_index, ontology_path}; @@ -114,6 +114,8 @@ script { (maud::PreEscaped(r#" }); // Poem: intercept POST forms, send via fetch, await SSE for DOM update. + // If the response body is non-empty HTML, morph the form's innerHTML with it + // (used for inline feedback without a page reload, e.g. auth forms). document.addEventListener('submit', async (e) => { const f = e.target; if (!f || f.tagName !== 'FORM') return; @@ -121,14 +123,19 @@ script { (maud::PreEscaped(r#" e.preventDefault(); const btn = f.querySelector('button[type="submit"], input[type="submit"]'); if (btn) { btn.disabled = true; btn.textContent = 'โ€ฆ'; } - await fetch(f.action, { + const resp = await fetch(f.action, { method: 'POST', body: new URLSearchParams(new FormData(f)), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'same-origin', }); - if (btn) { btn.disabled = false; btn.textContent = 'submit'; } - f.reset(); + const html = await resp.text(); + if (html && html.trim()) { + Idiomorph.morph(f, html, {morphStyle: 'innerHTML'}); + } else { + if (btn) { btn.disabled = false; btn.textContent = 'submit'; } + f.reset(); + } }); // Search: debounced fetch + idiomorph. diff --git a/server/static/theme_default.css b/server/static/theme_default.css index 9e71574da4bed3a0116c347610636780678bd1af..a1d8d1765a7812191edc579125facf1694554c2c 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -250,6 +250,11 @@ p.auth-error { font-size: 12px; margin: 4px 0 0; } +p.auth-success { + color: var(--signal); + font-size: 13px; + margin: 4px 0 0; +} /* ---------------------------------------------------------------- BUTTONS โ€” raised, press on :active diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css index dc9fa4654f529eb1843557fb580cf3982da46901..8ed8fd88efab32b36bd66cf200aa182219b0a8b5 100644 --- a/server/static/theme_retro.css +++ b/server/static/theme_retro.css @@ -32,6 +32,7 @@ input[type="text"] { input[type="text"]:focus { border-color: #00ff41; } p.auth-hint { color: #555; font-family: monospace; font-size: 0.75rem; margin: 0; } p.auth-error { color: #ff4444; font-family: monospace; font-size: 0.8rem; margin: 0; } +p.auth-success { color: #00ff41; font-family: monospace; font-size: 0.8rem; margin: 0; } /* Ingest form (poem pattern) */ .ingest-form-wrap { margin-top: 1.5rem; }