Side A performs a genuine type-safety refactor (newtype Deref impls, threading CanonicalItemUrl through resolve_item, rank-change computation, and pair RPC) that reduces stringly-typed bugs and clarifies invariants across core server logic. Side B is a legitimate but narrow UI feature (clickable unpin icon) plus CSS/test churn, which is useful but smaller in architectural impact than A's cross-cutting type correctness improvements.
constitution · epochs · watch · epoch 3
c_c42f908efc44 (tommy-mor) vs c_7ec4b410de02 (tommy-mor)
download prompt · raw event · cmp_21baed35ba9237
council reasoning
A threads CanonicalItemUrl through resolve_item, validation HashSets, scope-rank maps, pair pools, and connectivity stats while adding Deref on href newtypes, replacing repeated String/parse wrapping with typed APIs across the core server path. B only turns a static pin glyph into an unpin form in child rankings (plus CSS/test updates)—real UX polish, but a narrow UI hook versus A’s lasting type-boundary cleanup.
Side A performs a broad, type-safe refactor by changing `resolve_item` to return `CanonicalItemUrl`, propagating canonical URL types through validation, ranking, pair selection, and connectivity logic, and adding `Deref<Target=str>` for URL newtypes to reduce conversions. This removes repeated parsing/wrapping (`CanonicalItemUrl(it.clone())`, `parse(...)`) and makes canonical IDs first-class across the API, whereas Side B is a localized UI enhancement that makes pinned child-group icons clickable to unpin and adds corresponding browser tests and CSS.
sides
A — c_c42f908efc44 (tommy-mor)
message
[674964ef] refactor: Deref for href newtypes, CanonicalItemUrl through resolve_item - Implement Deref<Target=str> for GardenItemUrl, ForumThreadUrl, TildeOntologyPath - resolve_item returns CanonicalItemUrl; validate uses HashSet<CanonicalItemUrl> - compute_scope_rank_changes keys are CanonicalItemUrl; pair RPC uses Vec pool - pick_random_distinct_canonical; connectivity stats on &[CanonicalItemUrl] - Global rank unranked uses stored ids before GardenItemUrl mapping Made-with: Cursor
diff preview
diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index 03b3e77911ccd662bec8635345dafe2593cf242e..1b291db83df7364a026f2e147e0a29a70a399371 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -30,13 +30,13 @@ pub fn now_ms() -> i64 {
t.as_millis() as i64
}
-/// Resolve an item path as a first-class canonical path.
-pub fn resolve_item(item: &str) -> Result<String, String> {
+/// Resolve DSL/user input to a stored canonical item id.
+pub fn resolve_item(item: &str) -> Result<CanonicalItemUrl, String> {
let canonical = canonicalize_item(item);
if canonical.is_empty() {
return Err(format!("empty item path: `{}`", item));
}
- Ok(canonical)
+ Ok(CanonicalItemUrl(canonical))
}
pub fn parse_parent_specs(parent: Option<&String>) -> Vec<String> {
@@ -94,7 +94,7 @@ pub fn paginate_rankings(
(out_components, out_unranked)
}
-pub fn pick_random_distinct(items: &[String]) -> Option<(String, String)> {
+pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(CanonicalItemUrl, CanonicalItemUrl)> {
use rand::seq::SliceRandom;
if items.len() < 2 {
return None;
@@ -123,15 +123,12 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo
group.voted_pairs.contains(&(i, j))
}
-pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {
+pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[CanonicalItemUrl]) -> ConnectivityStats {
let n = pool.len();
let global_idxs: Vec<Option<usize>> = pool
.iter()
- .map(|it| {
- let key = CanonicalItemUrl(it.clone());
- group.item_to_idx.get(&key).copied()
- })
+ .map(|it| group.item_to_idx.get(it).copied())
.collect();
let present: Vec<usize> = global_idxs.iter().filter_map(|x| *x).collect();
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index cf22cb0129366c3aed031bc86f3197a4321cb806..a10ce662105cff8fad949c6b83f7035ce79bed18 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -24,7 +24,7 @@ pub use auth::{
pub use helpers::{
api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,
- parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path,
+ parse_parent_specs, pick_random_distinct_canonical, resolve_item, sha256_hex, vote_touches_path,
};
pub use rpc::handle_rpc_batch;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 5f7d50188f1381267402f2e57e671234ef5db2fd..de0955d0887d32740e1fd18365205c5bdb53c247 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -29,7 +29,7 @@ use crate::{
use super::auth::verify_bearer_principal;
use super::helpers::{
compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,
- pick_random_distinct, resolve_item, vote_touches_path,
+ pick_random_distinct_canonical, resolve_item, vote_touches_path,
};
use super::validate::{normalize_room_and_thread, validate_ingest_document};
@@ -146,21 +146,21 @@ fn authorize_room_read(reduced: &ReducerState, headers: &HeaderMap, room: &str)
}
fn compute_scope_rank_changes(
- parent: &str,
+ parent: &CanonicalItemUrl,
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>> {
+ fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap<CanonicalItemUrl, Option<RankPosition>> {
let mut map = HashMap::new();
for comp in &rankings.component_rankings {
let total = comp.ranked.len();
for (i, item) in comp.ranked.iter().enumerate() {
- map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total }));
+ map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total }));
}
}
for item in &rankings.unranked_items {
- map.insert(item.as_str().to_string(), None);
+ map.insert(item.clone(), None);
}
map
}
@@ -168,7 +168,7 @@ fn compute_scope_rank_changes(
let before_pos = build_positions(before);
let after_pos = build_positions(after);
- let all_items: std::collections::BTreeSet<String> = before_pos.keys().cloned()
+ let all_items: std::collections::BTreeSet<CanonicalItemUrl> = before_pos.keys().cloned()
.chain(after_pos.keys().cloned())
.collect();
@@ -184,7 +184,7 @@ fn compute_scope_rank_changes(
};
if changed {
changes.push(RankChange {
- item: GardenItemUrl::from_storage_str(&item, room_wire),
+ item: GardenItemUrl::from_stored(&item, room_wire),
before: b,
after: a,
});
@@ -203,11 +203,7 @@ fn compute_scope_rank_changes(
});
Some(ScopeRankChanges {
- parent: if parent.is_empty() {
- "/".to_string()
- } else {
- GardenItemUrl::from_storage_str(parent, room_wire).into_inner()
- },
+ parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),
changes,
})
}
@@ -473,8 +469,8 @@ async fn rpc_post(
for s in &v.doc.statements {
if let dsl::Stmt::Vote { item1, item2, .. } = s {
if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {
- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }
- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }
+ if let Some(p) = a.parent() { parents.insert(p); }
+ if let Some(p) = b.parent() { parents.insert(p); }
}
}
}
@@ -525,7 +521,7 @@ async fn rpc_post(
.filter_map(|p| {
let before = pre_rankings.get(p)?;
let after = crate::scope_rank::build_children_rankings(content, p);
- compute_scope_rank_changes(p.as_str(), before, &after, &room_key)
+ compute_scope_rank_changes(p, before, &after, &room_key)
})
.collect();
if v.is_empty() { None } else { Some(v) }
@@ -638,8 +634,8 @@ async fn rpc_check(
for s in &v.doc.statements {
if let dsl::Stmt::Vote { item1, item2, .. } = s {
if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {
- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }
- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }
+ if let Some(p) = a.parent() { parents.insert(p); }
+ if let Some(p) = b.parent() { parents.insert(p); }
}
}
}
@@ -961,7 +957,7 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<&
async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result<RpcResult, RpcErr> {
let scope = scope_from_room_wire(&room);
let reduced_arc = state.reduced.clone();
- let pool: Vec<String> = {
+ let pool: Vec<CanonicalItemUrl> = {
let reduced = reduced_arc.read().await;
let content = content_for_room(&reduced, &room);
let tmp = if parent_path.trim().is_empty() {
@@ -970,12 +966,11 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re
Some(parent_path.clone())
};
let specs = parse_parent_specs(tmp.as_ref());
- let raw_pool: Vec<CanonicalItemUrl> = if specs.is_empty() {
+ if specs.is_empty() {
content.ranking_group.idx_to_item.clone()
} else {
crate::scope_rank::resolve_scope(content, &specs)
- };
- raw_pool.into_iter().map(|it| it.0).collect()
+ }
};
if pool.len() < 2 {
return Err((
@@ -983,31 +978,30 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re
Some("add items via ingest".into()),
));
}
- let selected: Option<(String, String)> = {
+ let selected: Option<(CanonicalItemUrl, CanonicalItemUrl)> = {
let mut reduced = reduced_arc.write().await;
let content = reduced.content.entry(scope.clone()).or_default();
let group = &mut content.ranking_group;
if group.idx_to_item.is_empty() {
- pick_random_distinct(&pool)
+ pick_random_distinct_canonical(&pool)
} else {
let mut rng = rand::thread_rng();
- let idxs: Vec<usize> = pool.iter()
- .filter_map(|it| {
- let key = CanonicalItemUrl(it.clone());
- group.item_to_idx.get(&key).copied()
- })
+ let idxs: Vec<usize> = pool
+ .iter()
+ .filter_map(|it| group.item_to_idx.get(it).copied())
.collect();
let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);
- let ranked_set: HashSet<String> = ranked.iter().map(|r| r.item.as_str().to_string()).collect();
- let unsorted: Vec<String> = pool.iter()
+ let ranked_set: HashSet<CanonicalItemUrl> = ranked.iter().map(|r| r.item.clone()).collect();
+ let unsorted: Vec<CanonicalItemUrl> = pool
+ .iter()
.filter(|it| !ranked_set.contains(*it))
.cloned()
.collect();
- let mut pick: Option<(String, String)> = None;
+ let mut pick: Option<(CanonicalItemUrl, CanonicalItemUrl)> = None;
if !unsorted.is_empty() {
if let Some(left) = unsorted.choose(&mut rng).cloned() {
- let mut candidates: Vec<String> = if !ranked.is_empty() {
- ranked.iter().map(|r| r.item.as_str().to_string()).collect()
+ let mut candidates: Vec<CanonicalItemUrl> = if !ranked.is_empty() {
+ ranked.iter().map(|r| r.item.clone()).collect()
} else {
pool.clone()
};
@@ -1021,21 +1015,21 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re
let a = ranked[i].item.as_str();
let b = ranked[i + 1].item.as_str();
if a != b && !is_pair_voted(group, a, b) {
- pick = Some((a.to_string(), b.to_string()));
+ pick = Some((ranked[i].item.clone(), ranked[i + 1].item.clone()));
break;
}
}
if pick.is_none() {
for _ in 0..64 {
let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; };
- if a != b && !is_pair_voted(group, &a, &b) {
+ if a != b && !is_pair_voted(group, a.as_str(), b.as_str()) {
pick = Some((a, b));
break;
}
}
}
}
- pick.or_else(|| pick_random_distinct(&pool))
+ pick.or_else(|| pick_random_distinct_canonical(&pool))
}
};
let Some((left, right)) = selected else {
@@ -1043,8 +1037,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re
};
let reduced = reduced_arc.read().await;
let content = content_for_ro
… preview truncated; 8,683 characters omittedB — 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
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.