constitution · epochs · watch · epoch 3

comparison

c_55f1cdf12e22 (tommy-mor) vs c_7ec4b410de02 (tommy-mor)

download prompt · raw event · cmp_66b9ecb4dd4f83

council reasoning

openai/gpt-chat-latest · winner A · 10:1 · permalink

Side A implements a substantial new capability across the system: shareable room invites with OAuth redemption, room auditing, multi-capability grants, new RPCs/CLI commands, server routes, state handling, reducer updates, API/type changes, and integration tests. Side B is a focused UI improvement that makes existing pin icons in ranked child groups clickable to unpin, with corresponding CSS and browser test updates, but it is a relatively small usability enhancement compared with the new end-to-end functionality in Side A.

openai/gpt-5.3-chat · winner A · 9:1 · permalink

Side A introduces a full invite system end-to-end (RPC minting, auth redemption flow, state management, reducer updates, CLI commands, and integration tests), plus expands thread modeling with system items, while Side B only makes an existing pin icon clickable and adjusts CSS/tests. The former adds new core capabilities and data flows; the latter is a localized UI improvement.

openai/gpt-5.2-chat · winner A · 9:1 · permalink

Side A introduces a full invite system (RoomMintInvite, /join/:token flow, redemption applying GrantAdded events), RoomAudit RPC, timeline merging with ThreadItem::System, reducer support for invites, and comprehensive integration tests (test/invites.bb), significantly expanding core auth and room capabilities. Side B is a focused UI enhancement making ranked child group pin icons clickable to unpin, with CSS tweaks and a browser test, but does not alter core domain logic.

~anthropic/claude-sonnet-latest · winner A · 8:2 · permalink

Side A implements a full invite system feature spanning event sourcing, RPC endpoints, HTTP routes, CLI commands, reducer state, and integration tests—substantial, non-trivial, lasting functionality. Side B is a focused UI polish fix (making pin icons clickable to unpin) which is useful but much smaller in scope, touching only templating/CSS and browser test updates.

~x-ai/grok-latest · winner A · 16:1 · permalink

Commit A lands a full invite-access feature (mint/redeem RPCs, /join OAuth path, multi-cap grants, RoomAudit, reducer/timeline/types/CLI, and invites integration tests), which permanently expands core product capability. Commit B only turns an existing ranked-child pin glyph into a form that posts the already-supported set_garden_pin clear action, plus CSS/test tweaks—a small local UX affordance.

sides

A — c_55f1cdf12e22 (tommy-mor)

message

[00be3a29] invite system

diff preview

diff --git a/bb.edn b/bb.edn
index 50be8232847e672b3f273a2fb25ddd1d12adb7e2..818f850765370d12d58f239e287764fc3649d78b 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,14 +47,16 @@
                                            "RUST_LOG"      "info"})})))}
 
   test
-  {:doc "Full test suite: integration + auth + grants"
+  {:doc "Full test suite: integration + auth + grants + invites"
    :requires ([test.integration :as integration]
               [test.auth :as auth]
-              [test.grants :as grants])
+              [test.grants :as grants]
+              [test.invites :as invites])
    :task (do
            (integration/integration)
            (auth/auth-test)
-           (grants/grants-test))}
+           (grants/grants-test)
+           (invites/invites-test))}
 
   perf
   {:doc "Performance test: concurrent HTTP requests to detect blocking I/O"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index e5833b0b93d8e667b94c574ba2b0f8cb758ff3df..8eda9f485bd1f7392f1e34be27176c21e20354eb 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -105,6 +105,23 @@ enum ScopedCmd {
         #[arg(long)]
         json: bool,
     },
+
+    /// Mint a shareable invite link (24h TTL, in-memory until redeemed). Requires Manage on the room.
+    InviteLink {
+        /// Comma-separated: view, post, vote, add_item, manage
+        #[arg(long = "caps", value_delimiter = ',')]
+        caps: Vec<String>,
+        #[arg(long, default_value_t = 1)]
+        uses: usize,
+        #[arg(long)]
+        json: bool,
+    },
+
+    /// List principals granted access in this room (requires View or Manage)
+    Audit {
+        #[arg(long)]
+        json: bool,
+    },
 }
 
 #[derive(Subcommand, Debug)]
@@ -514,17 +531,35 @@ fn print_thread(resp: &ThreadDetailResponse) {
         .duration_since(std::time::UNIX_EPOCH)
         .unwrap_or_default()
         .as_millis() as i64;
-    if resp.total > resp.posts.len() {
-        let end = resp.offset + resp.posts.len();
-        eprintln!("# showing {}-{} of {} posts  (--offset N --limit N to paginate)", resp.offset, end.saturating_sub(1), resp.total);
+    if resp.total > resp.items.len() {
+        let end = resp.offset + resp.items.len();
+        eprintln!(
+            "# showing {}-{} of {} rows  (--offset N --limit N to paginate)",
+            resp.offset,
+            end.saturating_sub(1),
+            resp.total
+        );
     }
-    for (i, post) in resp.posts.iter().enumerate() {
-        let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts);
-        let body = &post.body.trim();
-        println!("<post index=\"{}\" timeago=\"{}\">", post.index, timeago);
-        println!("{}", body);
-        println!("</post>");
-        if i + 1 < resp.posts.len() {
+    for (i, item) in resp.items.iter().enumerate() {
+        match item {
+            ThreadItem::Post {
+                index,
+                ts,
+                body,
+                ..
+            } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                let body = body.trim();
+                println!("<post index=\"{}\" timeago=\"{}\">", index, timeago);
+                println!("{}", body);
+                println!("</post>");
+            }
+            ThreadItem::System { ts, text } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                println!("<system timeago=\"{}\">{}</system>", timeago, text.trim());
+            }
+        }
+        if i + 1 < resp.items.len() {
             println!();
             println!();
         }
@@ -1036,6 +1071,95 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
                 }
             }
         },
+        ScopedCmd::InviteLink { caps, uses, json } => {
+            let caps: Vec<String> = caps
+                .into_iter()
+                .flat_map(|s| {
+                    s.split(',')
+                        .map(|p| p.trim().to_lowercase())
+                        .filter(|p| !p.is_empty())
+                        .collect::<Vec<_>>()
+                })
+                .collect();
+            if caps.is_empty() {
+                return Err(anyhow!("--caps is required (e.g. --caps view,post,vote)"));
+            }
+            let bearer = effective_bearer().ok_or_else(|| {
+                anyhow!(
+                    "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                     then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                )
+            })?;
+            let batch = send_rpc(
+                &client,
+                base,
+                Some(&bearer),
+                vec![RpcCommand::RoomMintInvite {
+                    room: room.to_string(),
+                    capabilities: caps,
+                    max_uses: uses,
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomInviteMinted {
+                    invite_url,
+                    expires_at_ms,
+                    max_uses,
+                } => {
+                    if json {
+                        println!(
+                            "{}",
+                            serde_json::to_string_pretty(&serde_json::json!({
+                                "invite_url": invite_url,
+                                "expires_at_ms": expires_at_ms,
+                                "max_uses": max_uses,
+                            }))?
+                        );
+                    } else {
+                        println!("{invite_url}");
+                        println!("(Expires in 24 hours. Max uses: {max_uses})");
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
+        ScopedCmd::Audit { json } => {
+            let bearer = effective_bearer().ok_or_else(|| {
+                anyhow!(
+                    "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                     then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                )
+            })?;
+            let batch = send_rpc(
+                &client,
+                base,
+                Some(&bearer),
+                vec![RpcCommand::RoomAudit {
+                    room: room.to_string(),
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomAudit(resp) => {
+                    if json {
+                        println!("{}", serde_json::to_string_pretty(&resp)?);
+                    } else {
+                        println!("room {}", resp.room);
+                        if resp.grants.is_empty() {
+                            println!("(no grants recorded)");
+                        } else {
+                            let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0);
+                            for g in &resp.grants {
+                                let caps = g.capabilities.join(", ");
+                                println!("{:<width$}  {}", g.username, caps, width = w_user.max(8));
+                            }
+                        }
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
         ScopedCmd::Check { file, json } => {
             let mut text = String::new();
             match file {
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 995ce4a61d29b024c399c656134f541ecfd880cf..b45ba39419c84af8bf2333fc9b7d47e98525c45f 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,12 +12,61 @@ use tokio::sync::RwLock;
 
 use crate::{
     api::helpers::{api_error, now_ms, sha256_hex},
-    events::{Event, TokenIssued, UserRegistered},
+    events::{Event, GrantAdded, TokenIssued, UserRegistered},
     identity::{parse_agent, parse_username},
     html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
 
+/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
+const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
+
+async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
+    let now = now_ms();
+    let ga = {
+        let mut invites = state.invites.write().await;
+        let Some(inv) = invites.get_mut(invite_token) else {
+            return Err("invite not found".into());
+        };
+        if now > inv.expires_at_ms {
+            invites.remove(invite_token);
+            return Err("invite expired".into());
+        }
+        if inv.current_uses >= inv.max_uses {
+            return Err("invite exhausted".into());
+        }
+        inv.current_uses += 1;
+        Event::GrantAdded(GrantAdded {
+            ts: now,
+            room_id: inv.room_id.clone(),
+            username: grantee_username.to_string(),
+            capabilities: inv.capabilities.clone(),
+            granted_by: inv.inviter.clone(),
+        })
+    };
+
+    match state.event_log.append(&ga).await {
+        Ok(()) => {
+            let mut reduced = state.reduced.write().await;
+            reduced.apply_event(ga);
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get(invite_token) {
+                if inv.current_uses >= inv.max_uses {
+                    invites.remove(invite_token);
+                }
+            }
+            Ok(())
+        }
+        Err(e) => {
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get_mut(invite_token) {
+                inv.current_uses = inv.current_uses.saturating_sub(1);
+            }
+            Err(format!("{e}"))
+        }
+    }
+}
+
 fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSession>>> {
     state.pending_sessions.clone()
 }
@@ -115,6 +164,42 @@ pub struct AuthLoginQuery {
     pub session: String,
 }
 
+pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+    let token = token.trim().to_string();
+    if token.is_empty() {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+    let now = now_ms();
+    let valid = {
+        let invites = state.invites.read().await;
+        match invites.get(&token) {
+            None => false,
+            Some(inv) => now <= inv.expires_at_ms && inv.current_uses < inv.max_uses,
+        }
+    };
+    if !valid {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+
+    let session = format!("p_{}", uuid::Uuid::new_v4().simple());
+    let s = PendingSession {
+        agent: INVITE_BROWSER_AGENT.to_string(),
+        created_ts: now_ms(),
+        provider: None,
+        provider_id: None,
+        redeem_invite: Some(token),
+        complete: None,
+    };
+    state.pending_sessions.write().await.insert(session.clone(), s);
+
+    let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
+    Redirect::temporary(&format!(
+        "{public_url}/auth/login?session={}",
+        urlencoding::encode(&session)
+    ))
+    .into_response()
+}
+
 pub async fn get_auth_login(Query(q): Query<AuthLoginQuery>, State(state): State<AppState>) -> impl IntoResponse {
     // Redirect to Google auth endpoint.
     let sessions = pending_sessions(&state);
@@ -205,6 +290,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
         s.provider = Som

… preview truncated; 45,403 characters omitted

download full diff A

B — c_7ec4b410de02 (tommy-mor)

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 <cursoragent@cursor.com>

diff preview

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

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.