atproto Thingiverse but good
12

Configure Feed

Select the types of activity you want to include in your feed.

PM-67: logged-out hero band + /about route

Orual (Jun 29, 2026, 7:06 PM EDT) 5a817276 a00f8c4b

+423 -7
+2
AGENTS.md
··· 56 56 57 57 Before review or handoff, run at least `just fix` and the narrowest relevant tests. Use `just test-all` for substantial changes. 58 58 59 + In a fresh checkout or jj workspace, run `just check` once before `just fix`: `src/env.rs` is generated by `build.rs` from `POLYMODEL_*`, and `cargo fmt` fails to resolve the `env` module until a build has produced it. 60 + 59 61 ## Stack notes 60 62 61 63 - Dioxus 0.7 is the UI framework.
+53
assets/styling/about.css
··· 1 + .about-page { 2 + flex: 1; 3 + width: 100%; 4 + max-width: 52rem; 5 + margin: 0 auto; 6 + padding: clamp(1.5rem, 4vw, 3rem) clamp(1rem, 3vw, 2rem); 7 + display: flex; 8 + flex-direction: column; 9 + gap: 1.25rem; 10 + } 11 + 12 + .about-intro, 13 + .about-section { 14 + padding: clamp(1.25rem, 3vw, 2rem); 15 + display: flex; 16 + flex-direction: column; 17 + gap: 0.75rem; 18 + } 19 + 20 + .about-intro h1 { 21 + margin: 0; 22 + color: var(--color-emphasis); 23 + font-size: clamp(1.6rem, 3vw, 2.1rem); 24 + line-height: 1.15; 25 + } 26 + 27 + .about-section h2 { 28 + margin: 0; 29 + color: var(--color-emphasis); 30 + font-size: clamp(1.15rem, 2vw, 1.4rem); 31 + line-height: 1.2; 32 + } 33 + 34 + .about-intro .product-lede, 35 + .about-section p { 36 + margin: 0; 37 + max-width: 70ch; 38 + color: var(--color-text); 39 + line-height: 1.6; 40 + } 41 + 42 + .about-footnote { 43 + margin: 0.5rem 0 0; 44 + color: var(--color-subtle); 45 + font-family: var(--font-mono); 46 + font-size: 0.74rem; 47 + line-height: 1.5; 48 + } 49 + 50 + .about-page .button-secondary { 51 + align-self: flex-start; 52 + margin-top: 0.25rem; 53 + }
+105
assets/styling/home.css
··· 33 33 gap: 1rem; 34 34 } 35 35 36 + /* Logged-out hero band: a compact "what is this" affordance above the feed, 37 + shown only to anonymous visitors who haven't dismissed it. */ 38 + 39 + .hero-band { 40 + position: relative; 41 + display: flex; 42 + align-items: flex-start; 43 + gap: 1rem; 44 + margin-bottom: 1rem; 45 + padding: clamp(0.9rem, 2.5vw, 1.4rem) clamp(1rem, 3vw, 1.75rem); 46 + } 47 + 48 + .hero-band-body { 49 + flex: 1; 50 + min-width: 0; 51 + display: flex; 52 + flex-direction: column; 53 + gap: 0.85rem; 54 + padding-right: 1.5rem; 55 + } 56 + 57 + .hero-band-pitch { 58 + margin: 0; 59 + max-width: 60ch; 60 + color: var(--color-text); 61 + font-size: clamp(1rem, 2vw, 1.15rem); 62 + line-height: 1.45; 63 + } 64 + 65 + .hero-band-actions { 66 + display: flex; 67 + flex-wrap: wrap; 68 + align-items: center; 69 + gap: 0.75rem 1rem; 70 + } 71 + 72 + .hero-band-signin { 73 + display: flex; 74 + flex-wrap: wrap; 75 + align-items: stretch; 76 + gap: 0.5rem; 77 + } 78 + 79 + .hero-band-input { 80 + min-width: 0; 81 + width: 16rem; 82 + max-width: 100%; 83 + padding: 0.5rem 0.7rem; 84 + color: var(--color-text); 85 + background: var(--color-surface); 86 + border: 1px solid var(--color-border); 87 + font: inherit; 88 + } 89 + 90 + .hero-band-input:focus-visible { 91 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 92 + outline-offset: 1px; 93 + } 94 + 95 + .hero-band-about { 96 + color: var(--color-subtle); 97 + text-decoration: underline; 98 + text-underline-offset: 2px; 99 + } 100 + 101 + .hero-band-about:hover { 102 + color: var(--color-text); 103 + } 104 + 105 + .hero-band-dismiss { 106 + position: absolute; 107 + top: 0.4rem; 108 + right: 0.5rem; 109 + } 110 + 111 + .hero-dismiss { 112 + appearance: none; 113 + padding: 0.1rem 0.45rem; 114 + color: var(--color-subtle); 115 + background: transparent; 116 + border: 1px solid transparent; 117 + font-size: 1.25rem; 118 + line-height: 1; 119 + cursor: pointer; 120 + } 121 + 122 + .hero-dismiss:hover { 123 + color: var(--color-text); 124 + border-color: var(--color-border); 125 + } 126 + 127 + .hero-dismiss:focus-visible { 128 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 129 + outline-offset: 1px; 130 + } 131 + 36 132 .browse-results-grid, 37 133 .foundation-grid, 38 134 .state-grid { ··· 104 200 .foundation-grid, 105 201 .state-grid { 106 202 grid-template-columns: 1fr; 203 + } 204 + 205 + .hero-band-signin { 206 + width: 100%; 207 + } 208 + 209 + .hero-band-input { 210 + width: 100%; 211 + flex: 1; 107 212 } 108 213 }
+53
src/about.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + use crate::Route; 4 + 5 + #[component] 6 + pub(crate) fn About() -> Element { 7 + rsx! { 8 + main { class: "about-page", 9 + section { class: "about-intro blueprint-panel", 10 + span { class: "status-pill", "About" } 11 + h1 { "What Polymodel is" } 12 + p { class: "product-lede", 13 + "Polymodel is a place to share the things you make and remix the things other people make — " 14 + "built on an open network instead of a walled garden." 15 + } 16 + } 17 + 18 + section { class: "about-section blueprint-panel", 19 + h2 { "Why it's open" } 20 + p { 21 + "Polymodel is built on the AT Protocol, the same open network behind Bluesky. " 22 + "Your projects, your profile, and the people who follow your work live in your own account — " 23 + "not locked inside one company's database. If Polymodel disappears tomorrow, your models and " 24 + "your audience come with you." 25 + } 26 + p { 27 + "That means no single platform owns your catalogue, and other apps on the same network can read " 28 + "and build on what you publish here." 29 + } 30 + } 31 + 32 + section { class: "about-section blueprint-panel", 33 + h2 { "Remix, with credit" } 34 + p { 35 + "Most things are made by standing on someone else's work. Polymodel treats remixing as a " 36 + "first-class action: fork a model, change what you need, and publish the result with a clear " 37 + "line back to the original maker." 38 + } 39 + p { 40 + "Attribution travels with the project, so the people whose work you built on stay credited " 41 + "wherever your version ends up." 42 + } 43 + } 44 + 45 + p { class: "about-footnote", 46 + "Polymodel is an early demo. Expect rough edges, missing features, and the occasional placeholder " 47 + "while the network and the catalogue fill in." 48 + } 49 + 50 + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 51 + } 52 + } 53 + }
+3 -3
src/appview/actor.rs
··· 107 107 let did = session.session_info().await.0; 108 108 let session_info_ms = t_session.elapsed().as_millis(); 109 109 let t_resolve = std::time::Instant::now(); 110 - let resolved = views::resolve_handle(&state, &did).await; 110 + let resolved = views::resolve_handle(state, &did).await; 111 111 let resolve_handle_ms = t_resolve.elapsed().as_millis(); 112 112 match resolved { 113 113 Ok(handle_str) => { 114 114 let handle = Handle::new_owned(handle_str.clone()) 115 115 .map_err(|e| internal(format!("invalid session handle: {e}")))?; 116 116 let t_profile = std::time::Instant::now(); 117 - let profile = match views::polymodel_profile_view(&state, &did).await? { 117 + let profile = match views::polymodel_profile_view(state, &did).await? { 118 118 Some(profile) => profile, 119 119 None => { 120 120 // No polymodel profile record: fall back to the actor's 121 121 // Bluesky profile so the session carries their real avatar 122 122 // and display name. If that fetch fails, degrade to a 123 123 // handle-only profile rather than dropping the session. 124 - match fetch_bsky_profile(&state, &AtIdentifier::Did(did.clone())).await { 124 + match fetch_bsky_profile(state, &AtIdentifier::Did(did.clone())).await { 125 125 Ok(detailed) => { 126 126 bsky_session_profile(detailed, did.clone(), &handle_str) 127 127 }
+188
src/browse.rs
··· 1 + use dioxus::prelude::ServerFnError; 1 2 use dioxus::prelude::*; 2 3 use jacquard_common::xrpc::XrpcClient; 3 4 use polymodel_api::space_polymodel::library; 4 5 6 + use crate::Route; 5 7 use crate::client::PolymodelClient; 6 8 use crate::profile::profile_href; 9 + use crate::session::SessionIdentity; 7 10 use crate::thing_card::ThingCard; 8 11 use crate::thing_detail::thing_detail_href; 12 + 13 + /// Cookie set client-side when the logged-out hero band is dismissed; read 14 + /// server-side so SSR omits the band on return without a hydration flash. 15 + const HERO_DISMISS_COOKIE: &str = "pm_hero_dismissed"; 9 16 10 17 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 11 18 enum FeedAlgorithm { ··· 53 60 } 54 61 } 55 62 63 + /// Whether the logged-out hero band should render. It shows only to a 64 + /// definitively anonymous visitor who has not dismissed it; an unresolved 65 + /// session (`Unknown`), a server-evaluated dismissal seed of `true`, or a 66 + /// live client-read dismissal cookie each suppress it. 67 + /// 68 + /// `hero_dismissed_seed` is the SSR/first-paint decision (server cookie read, 69 + /// cached through hydration), so the band is correct in the initial markup 70 + /// with no flash. `client_dismissed` is the post-hydration backstop: a fresh 71 + /// read of the live cookie that keeps the band suppressed across client-side 72 + /// navigation, where the cached seed would otherwise still read `false`. 73 + fn should_show_hero( 74 + session: &SessionIdentity, 75 + hero_dismissed_seed: Option<&Result<bool, ServerFnError>>, 76 + client_dismissed: bool, 77 + ) -> bool { 78 + !client_dismissed 79 + && matches!(session, SessionIdentity::Anonymous) 80 + && !matches!(hero_dismissed_seed, Some(Ok(true))) 81 + } 82 + 83 + /// True when the cookie header carries the named flag (presence only; the 84 + /// dismiss control always sets it to `1`). Pure so it stays unit-testable. 85 + // Only reached from the wasm client read and the unit tests; on native 86 + // non-test builds it is legitimately unreferenced. 87 + #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] 88 + fn cookie_has_flag(cookie_header: &str, name: &str) -> bool { 89 + cookie_header 90 + .split(';') 91 + .filter_map(|pair| pair.split_once('=')) 92 + .any(|(key, _)| key.trim() == name) 93 + } 94 + 95 + /// Read the dismissal cookie from the browser on the client. Returns `false` 96 + /// on the server (where SSR's `hero_dismissed_seed` owns the decision) and on 97 + /// non-wasm targets. 98 + #[cfg(target_arch = "wasm32")] 99 + fn client_cookie_dismissed() -> bool { 100 + use wasm_bindgen::JsValue; 101 + 102 + let Some(document) = web_sys::window().and_then(|window| window.document()) else { 103 + return false; 104 + }; 105 + // `Document::cookie` lives behind the `HtmlDocument` web-sys feature, which 106 + // isn't enabled; read the property reflectively to avoid widening features. 107 + let cookie = js_sys::Reflect::get(document.as_ref(), &JsValue::from_str("cookie")) 108 + .ok() 109 + .and_then(|value| value.as_string()) 110 + .unwrap_or_default(); 111 + cookie_has_flag(&cookie, HERO_DISMISS_COOKIE) 112 + } 113 + 114 + #[cfg(not(target_arch = "wasm32"))] 115 + fn client_cookie_dismissed() -> bool { 116 + false 117 + } 118 + 56 119 #[allow(clippy::useless_format)] 57 120 #[component] 58 121 pub(crate) fn Browse() -> Element { ··· 86 149 let feed_result = feed.read(); 87 150 let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); 88 151 152 + // Show the logged-out hero band only to definitively anonymous visitors who 153 + // haven't dismissed it. Both inputs resolve server-side during SSR (session 154 + // via the App seed, dismissal via the cookie server fn), so the band is 155 + // present or absent in the initial markup with no post-hydration flash. 156 + let session = use_context::<Signal<SessionIdentity>>(); 157 + let hero_dismissed = use_server_future(hero_dismissed_seed)?; 158 + let client_dismissed = use_hook(client_cookie_dismissed); 159 + let show_hero = should_show_hero(&session.read(), hero_dismissed().as_ref(), client_dismissed); 160 + 89 161 rsx! { 90 162 main { class: "browse-page", 163 + 164 + if show_hero { 165 + HeroBand {} 166 + } 91 167 92 168 section { class: "browse-feed", aria_label: "Catalog feed", 93 169 div { class: "tab-row", role: "tablist", aria_label: "Feeds", ··· 150 226 } 151 227 } 152 228 229 + #[component] 230 + fn HeroBand() -> Element { 231 + // Rendered as raw HTML so the dismiss control carries a literal `onclick` 232 + // that fires before (or without) wasm hydration. A Dioxus event handler 233 + // would require the wasm bundle to be live, defeating the no-flash goal. 234 + // 235 + // Hiding via `style.display` rather than removing the node keeps Dioxus 236 + // hydration reconciliation intact: `style` is an attribute Dioxus never 237 + // rendered here, so re-renders of this same node (feed loads, tab 238 + // switches) won't clobber the hidden state. A *remount* (client-side 239 + // navigation back to `/` builds a fresh node) does drop the inline style, 240 + // so the cookie this sets is also read client-side in `should_show_hero` 241 + // to keep a dismissed band suppressed across navigation. 242 + let dismiss_html = format!( 243 + "<button type=\"button\" class=\"hero-dismiss\" aria-label=\"Dismiss introduction\" \ 244 + onclick=\"document.cookie='{cookie}=1; path=/; max-age=31536000; samesite=lax'; \ 245 + var b=this.closest('.hero-band'); if(b){{b.style.display='none';}}\">&times;</button>", 246 + cookie = HERO_DISMISS_COOKIE 247 + ); 248 + 249 + rsx! { 250 + section { class: "hero-band blueprint-panel", aria_label: "About Polymodel", 251 + div { class: "hero-band-body", 252 + p { class: "hero-band-pitch", 253 + "Share your creations. Make things, remix them. Without getting locked in to a closed platform." 254 + } 255 + div { class: "hero-band-actions", 256 + form { class: "hero-band-signin", action: "/oauth/start", method: "get", 257 + input { r#type: "hidden", name: "return_to", value: "/" } 258 + input { 259 + class: "hero-band-input", 260 + r#type: "text", 261 + name: "identifier", 262 + placeholder: "you.bsky.social", 263 + aria_label: "Bluesky handle", 264 + } 265 + button { class: "button button-secondary", r#type: "submit", "Sign in" } 266 + } 267 + Link { class: "hero-band-about", to: Route::About {}, "what's this?" } 268 + } 269 + } 270 + div { class: "hero-band-dismiss", dangerous_inner_html: dismiss_html } 271 + } 272 + } 273 + } 274 + 275 + #[server] 276 + async fn hero_dismissed_seed() -> Result<bool, ServerFnError> { 277 + use axum_extra::extract::cookie::CookieJar; 278 + use dioxus::prelude::dioxus_fullstack::FullstackContext; 279 + 280 + let context = FullstackContext::current() 281 + .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?; 282 + let parts = context.parts_mut().clone(); 283 + let jar = CookieJar::from_headers(&parts.headers); 284 + Ok(jar.get(HERO_DISMISS_COOKIE).is_some()) 285 + } 286 + 153 287 #[cfg(test)] 154 288 mod tests { 155 289 use super::*; ··· 170 304 BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), 171 305 state => panic!("expected populated state, got {state:?}"), 172 306 } 307 + } 308 + 309 + #[test] 310 + fn hero_shows_only_for_anonymous_and_undismissed() { 311 + use crate::session::AuthenticatedIdentity; 312 + use jacquard_common::types::string::{Did, Handle}; 313 + 314 + assert!(should_show_hero(&SessionIdentity::Anonymous, None, false)); 315 + assert!(should_show_hero( 316 + &SessionIdentity::Anonymous, 317 + Some(&Ok(false)), 318 + false 319 + )); 320 + // Either dismissal signal suppresses the band: the SSR seed... 321 + assert!(!should_show_hero( 322 + &SessionIdentity::Anonymous, 323 + Some(&Ok(true)), 324 + false 325 + )); 326 + // ...or the live client-read cookie (e.g. after client-side navigation). 327 + assert!(!should_show_hero( 328 + &SessionIdentity::Anonymous, 329 + Some(&Ok(false)), 330 + true 331 + )); 332 + // An unresolved session must not flash the band before identity lands. 333 + assert!(!should_show_hero(&SessionIdentity::Unknown, None, false)); 334 + 335 + let authed = SessionIdentity::Authenticated(AuthenticatedIdentity { 336 + avatar: None, 337 + did: Did::new_owned("did:plc:tester").unwrap(), 338 + handle: Handle::new_owned("tester.test").unwrap(), 339 + display_name: None, 340 + }); 341 + assert!(!should_show_hero(&authed, None, false)); 342 + } 343 + 344 + #[test] 345 + fn cookie_flag_detects_presence_only_among_other_cookies() { 346 + assert!(cookie_has_flag("pm_hero_dismissed=1", HERO_DISMISS_COOKIE)); 347 + assert!(cookie_has_flag( 348 + "session=abc; pm_hero_dismissed=1; theme=dark", 349 + HERO_DISMISS_COOKIE 350 + )); 351 + assert!(!cookie_has_flag( 352 + "session=abc; theme=dark", 353 + HERO_DISMISS_COOKIE 354 + )); 355 + assert!(!cookie_has_flag("", HERO_DISMISS_COOKIE)); 356 + // Must not match a cookie whose name merely contains the flag. 357 + assert!(!cookie_has_flag( 358 + "not_pm_hero_dismissed=1", 359 + HERO_DISMISS_COOKIE 360 + )); 173 361 } 174 362 }
+6
src/main.rs
··· 4 4 use polymodel_api::space_polymodel::actor::bootstrap_profile::BootstrapProfile; 5 5 use polymodel_api::space_polymodel::actor::get_session::{GetSession, GetSessionOutput}; 6 6 7 + mod about; 7 8 mod auth; 8 9 mod browse; 9 10 mod client; ··· 31 32 #[cfg(any(feature = "server", test))] 32 33 mod oauth; 33 34 35 + use about::About; 34 36 use browse::Browse; 35 37 use client::PolymodelClient; 36 38 use foundation::Foundation; ··· 53 55 const VIEWER_CSS: Asset = asset!("/assets/styling/viewer.css"); 54 56 const PUBLISH_CSS: Asset = asset!("/assets/styling/publish.css"); 55 57 const PLACEHOLDERS_CSS: Asset = asset!("/assets/styling/placeholders.css"); 58 + const ABOUT_CSS: Asset = asset!("/assets/styling/about.css"); 56 59 57 60 #[cfg(feature = "server")] 58 61 fn main() { ··· 121 124 Browse {}, 122 125 #[route("/foundation")] 123 126 Foundation {}, 127 + #[route("/about")] 128 + About {}, 124 129 #[route("/search?:q")] 125 130 Search { q: String }, 126 131 #[route("/publish?:draft")] ··· 194 199 Stylesheet { href: VIEWER_CSS } 195 200 Stylesheet { href: PUBLISH_CSS } 196 201 Stylesheet { href: PLACEHOLDERS_CSS } 202 + Stylesheet { href: ABOUT_CSS } 197 203 198 204 Router::<Route> {} 199 205 ToastShelf { toasts }
+1 -3
src/profile.rs
··· 3 3 use jacquard_common::types::ident::AtIdentifier; 4 4 use jacquard_common::types::string::{Did, Handle, UriValue}; 5 5 use jacquard_common::xrpc::XrpcClient; 6 - use polymodel_api::space_polymodel::actor::{ 7 - ProfileViewUnion, get_profile::GetProfile, 8 - }; 6 + use polymodel_api::space_polymodel::actor::{ProfileViewUnion, get_profile::GetProfile}; 9 7 use polymodel_api::space_polymodel::library; 10 8 use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings; 11 9
+12 -1
src/shell.rs
··· 16 16 let route = use_route::<Route>(); 17 17 let show_browse_link = !matches!(route, Route::Browse {}); 18 18 19 + // One orange (`--color-action`) per view: while logged out the primary 20 + // action is Sign in (in `SessionControl`), so Publish drops to secondary 21 + // until the visitor is authenticated and can actually publish. `Unknown` 22 + // (pre-resolution) stays secondary to avoid an orange flash for visitors 23 + // who turn out anonymous. 24 + let publish_class = if matches!(&*session.read(), SessionIdentity::Authenticated(_)) { 25 + "button button-primary app-publish-cta" 26 + } else { 27 + "button button-secondary app-publish-cta" 28 + }; 29 + 19 30 rsx! { 20 31 div { class: "app-shell", 21 32 header { class: "app-header", ··· 32 43 } 33 44 } 34 45 ShellSearch {} 35 - Link { class: "button button-primary app-publish-cta", to: Route::Publish { draft: String::new() }, "Publish" } 46 + Link { class: publish_class, to: Route::Publish { draft: String::new() }, "Publish" } 36 47 SessionControl { session, toasts } 37 48 } 38 49 }