Side B unifies the auth flow with the shared layout/JS pattern (removing a bespoke redirect-based layout, adding inline error/success fragments and generalizing the poem-JS morph behavior), which is a real architectural simplification with broader reuse. Side A is a solid, well-tested UX addition (unpin from ranked list) but is more narrowly scoped and mostly wiring/CSS/test changes rather than structural improvement.
constitution · epochs · watch · epoch 3
c_7ec4b410de02 (tommy-mor) vs c_64faa3bee86f (tommy-mor)
download prompt · raw event · cmp_5605c8b79e9c62
council reasoning
B redesigns auth POST handling to return HTML fragments and extends the shared poem form interceptor to morph non-empty responses, replacing redirect round-trips with inline error/success—structural UX that affects the whole auth path. A only wires an existing set_garden_pin clear action onto ranked-child pin glyphs (plus CSS/tests), a useful but localized consistency fix.
Side B changes the auth flow architecture by returning HTML fragments instead of redirects, refactoring the username form into reusable rendering helpers, and extending the shared Poem JavaScript to morph a form's innerHTML when a POST returns HTML, enabling inline form updates without page reloads while preserving existing empty-response behavior. Side A is a solid UX improvement that makes ranked child-group pin icons submit the existing unpin action and adds browser coverage, but it is a narrower feature built on an existing flow rather than a reusable interaction pattern.
sides
A — 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
B — c_64faa3bee86f (tommy-mor)
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) <noreply@anthropic.com>diff preview
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; }
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.