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: [6120bd96] fix redirect urls for custom hosts and deploy from staging Use HOSTNAME and window.location.origin instead of hardcoded staging.sorter.social, and trigger fly deploys on pushes to staging. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index 3e693915fe6f99a5c2221b2921bf8ebc305180fc..5e11e5c312f62bed34d8cc68bd4a5538f52476b9 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -3,11 +3,11 @@ name: deploy to fly.io on: push: branches: - - main + - staging workflow_dispatch: concurrency: - group: fly-deploy-main + group: fly-deploy-staging cancel-in-progress: true jobs: diff --git a/borter/src/app/linear.clj b/borter/src/app/linear.clj index f77d8a974e0cf05f9c33233bb625bdb190d2007b..5ef0e34cf1d543826675c6facb66aec079b40561 100644 --- a/borter/src/app/linear.clj +++ b/borter/src/app/linear.clj @@ -6,6 +6,7 @@ [ring.util.response :as response] [app.database :as db] [app.permissions :as perms] + [app.util :as util] [honey.sql.helpers :as h] [sluj.core :refer [sluj]])) @@ -13,9 +14,7 @@ (def client-secret (System/getenv "BORTER_LINEAR_CLIENT_SECRET")) (defn get-hostname [] - (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost)) - "sorter.social" "https://sorter.social/api/linear/callback" - "http://localhost:3000/api/linear/callback")) + (str (util/get-base-url) "/api/linear/callback")) (defn code->token [code] (def code code) diff --git a/borter/src/app/login.clj b/borter/src/app/login.clj index 5d545db9bef7765ba8b3fe7d00261c8ce84e9e1a..86817f8e94bc174e5b108859cc0b342953ab7acb 100644 --- a/borter/src/app/login.clj +++ b/borter/src/app/login.clj @@ -1,6 +1,7 @@ (ns app.login (:require [crypto.password.bcrypt :as password] [app.database :as db] + [app.util :as util] [honey.sql.helpers :as h] [hato.client :as hc] [clojure.data.json :as json] @@ -12,10 +13,7 @@ (def environment (or (System/getenv "ENVIRONMENT") "development")) (defn get-base-url [] - (case environment - "production" "https://sorter.social" - "staging" "https://staging.sorter.social" - "development" "http://localhost:3000")) ; fallback for development + (util/get-base-url)) (defn create-email-content "Creates a standardized email structure with customizable content" diff --git a/borter/src/app/oauth.clj b/borter/src/app/oauth.clj index 1166f2ee30c9e813840711c72d1ad8f1c62e1ffe..2cd0c62f1fe267f7f5f725a97bc3ba0d0889517f 100644 --- a/borter/src/app/oauth.clj +++ b/borter/src/app/oauth.clj @@ -3,6 +3,7 @@ [clojure.data.json :as json] [hato.client :as hc] [app.database :as db] + [app.util :as util] [honey.sql.helpers :as h] [ring.util.codec :as codec]) (:import [java.util Base64])) @@ -13,12 +14,7 @@ encoded-bytes)) (defn get-hostname [] - (let [env (System/getenv "ENVIRONMENT")] - (println "Current environment:" env) - (case env - "production" "https://sorter.social" - "staging" "https://staging.sorter.social" - "http://localhost:3000"))) + (util/get-base-url)) (defn get-origin-hostname [req] (let [origin (get-in req [:headers "origin"]) diff --git a/borter/src/app/spotify.clj b/borter/src/app/spotify.clj index 3e11713119141812fa2707ec956fb7ed612ee69a..b38d734351a1d4f1e142d93d4435ffe7bd20c02d 100644 --- a/borter/src/app/spotify.clj +++ b/borter/src/app/spotify.clj @@ -1,5 +1,6 @@ (ns app.spotify (:require [app.oauth :as oauth] + [app.util :as util] [hato.client :as hc] [clojure.data.json :as json] [ring.util.codec :as codec] @@ -47,10 +48,7 @@ (defn get-hostname [] - (case (System/getenv "ENVIRONMENT") - "production" "https://sorter.social" - "staging" "https://staging.sorter.social" - "http://localhost:3000")) + (util/get-base-url)) (defn create-spotify-tag "Creates a tag for a Spotify entity (artist, album, track) if it doesn't exist" diff --git a/borter/src/app/twitter.clj b/borter/src/app/twitter.clj index ef7b18fa1fcb82bb944b3035ffed44499181237f..b9a3fdccc15d13918a4d11d8c0443de94fd172b4 100644 --- a/borter/src/app/twitter.clj +++ b/borter/src/app/twitter.clj @@ -1,6 +1,7 @@ (ns app.twitter (:require [app.database :as db] [app.permissions :as perms] + [app.util :as util] [honey.sql.helpers :as h] [hato.client :as hc] [clojure.data.json :as json] @@ -37,9 +38,7 @@ (clojure.string/join "&" (map (fn [[k v]] (str (name k) "=" v)) params))) (defn get-hostname [] - (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost)) - "sorter.isnt.online" "https://sorter.isnt.online/api/twitter/callback" - "http://localhost:3000/api/twitter/callback")) + (str (util/get-base-url) "/api/twitter/callback")) (defn encode-b64 [s] (.encodeToString (java.util.Base64/getEncoder) (.getBytes s))) diff --git a/borter/src/app/util.clj b/borter/src/app/util.clj index 384a64306c84aa8288ec44cd5de57edc248a826d..6a8aa0875accb28dc81bf57e645e3961b0a3ace5 100644 --- a/borter/src/app/util.clj +++ b/borter/src/app/util.clj @@ -2,6 +2,18 @@ (:require [clojure.string :as string]) (:import [java.net URLEncoder])) +(defn get-base-url + "Public site base URL for redirects and oauth callbacks." + [] + (if-let [hostname (not-empty (System/getenv "HOSTNAME"))] + (if (string/starts-with? hostname "http") + (string/replace hostname #"/$" "") + (str "https://" (string/replace hostname #"/$" ""))) + (case (or (System/getenv "ENVIRONMENT") "development") + "production" "https://sorter.social" + "staging" "https://staging.sorter.social" + "http://localhost:3000"))) + (defn urlencode-params [params] (clojure.string/join "&" (map (fn [[k v]] (str k "=" (URLEncoder/encode (str v) "UTF-8"))) params))) diff --git a/borter/src/app/youtube.clj b/borter/src/app/youtube.clj index 647ef7c6d4f2503a63a7c074d46dc815b2a23547..fc9a2f5ed516692f19e06b4c0221f510547cf8c0 100644 --- a/borter/src/app/youtube.clj +++ b/borter/src/app/youtube.clj @@ -6,6 +6,7 @@ [ring.util.response :as response] [app.database :as db] [app.permissions :as perms] + [app.util :as util] [honey.sql.helpers :as h] [sluj.core :refer [sluj]])) @@ -32,9 +33,7 @@ :body (json/read-str {:key-fn keyword}))) (defn get-hostname [] - (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost)) - "localhost" "http://localhost:3000/api/youtube/callback" - "https://sorter.isnt.online/api/youtube/callback")) + (str (util/get-base-url) "/api/youtube/callback")) diff --git a/forter/src/utils/authUtils.js b/forter/src/utils/authUtils.js index 145e5db7ad6f7a6439d68c63beb4d07d31b2de08..98ed7fa671f9887f44dc1479d85569951eb4773e 100644 --- a/forter/src/utils/authUtils.js +++ b/forter/src/utils/authUtils.js @@ -5,8 +5,11 @@ import { current_session, fetchSession } from "../session"; let lastSyncTime = 0; const SYNC_THROTTLE_MS = 5000; // Only sync once every 5 seconds -// Get base URL based on Vite's mode +// Get base URL based on current origin, with build-mode fallbacks for SSR/build const getBaseUrl = () => { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } switch (import.meta.env.MODE) { case 'production': return 'https://sorter.social';