···56565757Before review or handoff, run at least `just fix` and the narrowest relevant tests. Use `just test-all` for substantial changes.
58585959+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.
6060+5961## Stack notes
60626163- Dioxus 0.7 is the UI framework.
···11+use dioxus::prelude::*;
22+33+use crate::Route;
44+55+#[component]
66+pub(crate) fn About() -> Element {
77+ rsx! {
88+ main { class: "about-page",
99+ section { class: "about-intro blueprint-panel",
1010+ span { class: "status-pill", "About" }
1111+ h1 { "What Polymodel is" }
1212+ p { class: "product-lede",
1313+ "Polymodel is a place to share the things you make and remix the things other people make — "
1414+ "built on an open network instead of a walled garden."
1515+ }
1616+ }
1717+1818+ section { class: "about-section blueprint-panel",
1919+ h2 { "Why it's open" }
2020+ p {
2121+ "Polymodel is built on the AT Protocol, the same open network behind Bluesky. "
2222+ "Your projects, your profile, and the people who follow your work live in your own account — "
2323+ "not locked inside one company's database. If Polymodel disappears tomorrow, your models and "
2424+ "your audience come with you."
2525+ }
2626+ p {
2727+ "That means no single platform owns your catalogue, and other apps on the same network can read "
2828+ "and build on what you publish here."
2929+ }
3030+ }
3131+3232+ section { class: "about-section blueprint-panel",
3333+ h2 { "Remix, with credit" }
3434+ p {
3535+ "Most things are made by standing on someone else's work. Polymodel treats remixing as a "
3636+ "first-class action: fork a model, change what you need, and publish the result with a clear "
3737+ "line back to the original maker."
3838+ }
3939+ p {
4040+ "Attribution travels with the project, so the people whose work you built on stay credited "
4141+ "wherever your version ends up."
4242+ }
4343+ }
4444+4545+ p { class: "about-footnote",
4646+ "Polymodel is an early demo. Expect rough edges, missing features, and the occasional placeholder "
4747+ "while the network and the catalogue fill in."
4848+ }
4949+5050+ Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" }
5151+ }
5252+ }
5353+}
+3-3
src/appview/actor.rs
···107107 let did = session.session_info().await.0;
108108 let session_info_ms = t_session.elapsed().as_millis();
109109 let t_resolve = std::time::Instant::now();
110110- let resolved = views::resolve_handle(&state, &did).await;
110110+ let resolved = views::resolve_handle(state, &did).await;
111111 let resolve_handle_ms = t_resolve.elapsed().as_millis();
112112 match resolved {
113113 Ok(handle_str) => {
114114 let handle = Handle::new_owned(handle_str.clone())
115115 .map_err(|e| internal(format!("invalid session handle: {e}")))?;
116116 let t_profile = std::time::Instant::now();
117117- let profile = match views::polymodel_profile_view(&state, &did).await? {
117117+ let profile = match views::polymodel_profile_view(state, &did).await? {
118118 Some(profile) => profile,
119119 None => {
120120 // No polymodel profile record: fall back to the actor's
121121 // Bluesky profile so the session carries their real avatar
122122 // and display name. If that fetch fails, degrade to a
123123 // handle-only profile rather than dropping the session.
124124- match fetch_bsky_profile(&state, &AtIdentifier::Did(did.clone())).await {
124124+ match fetch_bsky_profile(state, &AtIdentifier::Did(did.clone())).await {
125125 Ok(detailed) => {
126126 bsky_session_profile(detailed, did.clone(), &handle_str)
127127 }
+188
src/browse.rs
···11+use dioxus::prelude::ServerFnError;
12use dioxus::prelude::*;
23use jacquard_common::xrpc::XrpcClient;
34use polymodel_api::space_polymodel::library;
4566+use crate::Route;
57use crate::client::PolymodelClient;
68use crate::profile::profile_href;
99+use crate::session::SessionIdentity;
710use crate::thing_card::ThingCard;
811use crate::thing_detail::thing_detail_href;
1212+1313+/// Cookie set client-side when the logged-out hero band is dismissed; read
1414+/// server-side so SSR omits the band on return without a hydration flash.
1515+const HERO_DISMISS_COOKIE: &str = "pm_hero_dismissed";
9161017#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1118enum FeedAlgorithm {
···5360 }
5461}
55626363+/// Whether the logged-out hero band should render. It shows only to a
6464+/// definitively anonymous visitor who has not dismissed it; an unresolved
6565+/// session (`Unknown`), a server-evaluated dismissal seed of `true`, or a
6666+/// live client-read dismissal cookie each suppress it.
6767+///
6868+/// `hero_dismissed_seed` is the SSR/first-paint decision (server cookie read,
6969+/// cached through hydration), so the band is correct in the initial markup
7070+/// with no flash. `client_dismissed` is the post-hydration backstop: a fresh
7171+/// read of the live cookie that keeps the band suppressed across client-side
7272+/// navigation, where the cached seed would otherwise still read `false`.
7373+fn should_show_hero(
7474+ session: &SessionIdentity,
7575+ hero_dismissed_seed: Option<&Result<bool, ServerFnError>>,
7676+ client_dismissed: bool,
7777+) -> bool {
7878+ !client_dismissed
7979+ && matches!(session, SessionIdentity::Anonymous)
8080+ && !matches!(hero_dismissed_seed, Some(Ok(true)))
8181+}
8282+8383+/// True when the cookie header carries the named flag (presence only; the
8484+/// dismiss control always sets it to `1`). Pure so it stays unit-testable.
8585+// Only reached from the wasm client read and the unit tests; on native
8686+// non-test builds it is legitimately unreferenced.
8787+#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))]
8888+fn cookie_has_flag(cookie_header: &str, name: &str) -> bool {
8989+ cookie_header
9090+ .split(';')
9191+ .filter_map(|pair| pair.split_once('='))
9292+ .any(|(key, _)| key.trim() == name)
9393+}
9494+9595+/// Read the dismissal cookie from the browser on the client. Returns `false`
9696+/// on the server (where SSR's `hero_dismissed_seed` owns the decision) and on
9797+/// non-wasm targets.
9898+#[cfg(target_arch = "wasm32")]
9999+fn client_cookie_dismissed() -> bool {
100100+ use wasm_bindgen::JsValue;
101101+102102+ let Some(document) = web_sys::window().and_then(|window| window.document()) else {
103103+ return false;
104104+ };
105105+ // `Document::cookie` lives behind the `HtmlDocument` web-sys feature, which
106106+ // isn't enabled; read the property reflectively to avoid widening features.
107107+ let cookie = js_sys::Reflect::get(document.as_ref(), &JsValue::from_str("cookie"))
108108+ .ok()
109109+ .and_then(|value| value.as_string())
110110+ .unwrap_or_default();
111111+ cookie_has_flag(&cookie, HERO_DISMISS_COOKIE)
112112+}
113113+114114+#[cfg(not(target_arch = "wasm32"))]
115115+fn client_cookie_dismissed() -> bool {
116116+ false
117117+}
118118+56119#[allow(clippy::useless_format)]
57120#[component]
58121pub(crate) fn Browse() -> Element {
···86149 let feed_result = feed.read();
87150 let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref());
88151152152+ // Show the logged-out hero band only to definitively anonymous visitors who
153153+ // haven't dismissed it. Both inputs resolve server-side during SSR (session
154154+ // via the App seed, dismissal via the cookie server fn), so the band is
155155+ // present or absent in the initial markup with no post-hydration flash.
156156+ let session = use_context::<Signal<SessionIdentity>>();
157157+ let hero_dismissed = use_server_future(hero_dismissed_seed)?;
158158+ let client_dismissed = use_hook(client_cookie_dismissed);
159159+ let show_hero = should_show_hero(&session.read(), hero_dismissed().as_ref(), client_dismissed);
160160+89161 rsx! {
90162 main { class: "browse-page",
163163+164164+ if show_hero {
165165+ HeroBand {}
166166+ }
9116792168 section { class: "browse-feed", aria_label: "Catalog feed",
93169 div { class: "tab-row", role: "tablist", aria_label: "Feeds",
···150226 }
151227}
152228229229+#[component]
230230+fn HeroBand() -> Element {
231231+ // Rendered as raw HTML so the dismiss control carries a literal `onclick`
232232+ // that fires before (or without) wasm hydration. A Dioxus event handler
233233+ // would require the wasm bundle to be live, defeating the no-flash goal.
234234+ //
235235+ // Hiding via `style.display` rather than removing the node keeps Dioxus
236236+ // hydration reconciliation intact: `style` is an attribute Dioxus never
237237+ // rendered here, so re-renders of this same node (feed loads, tab
238238+ // switches) won't clobber the hidden state. A *remount* (client-side
239239+ // navigation back to `/` builds a fresh node) does drop the inline style,
240240+ // so the cookie this sets is also read client-side in `should_show_hero`
241241+ // to keep a dismissed band suppressed across navigation.
242242+ let dismiss_html = format!(
243243+ "<button type=\"button\" class=\"hero-dismiss\" aria-label=\"Dismiss introduction\" \
244244+ onclick=\"document.cookie='{cookie}=1; path=/; max-age=31536000; samesite=lax'; \
245245+ var b=this.closest('.hero-band'); if(b){{b.style.display='none';}}\">×</button>",
246246+ cookie = HERO_DISMISS_COOKIE
247247+ );
248248+249249+ rsx! {
250250+ section { class: "hero-band blueprint-panel", aria_label: "About Polymodel",
251251+ div { class: "hero-band-body",
252252+ p { class: "hero-band-pitch",
253253+ "Share your creations. Make things, remix them. Without getting locked in to a closed platform."
254254+ }
255255+ div { class: "hero-band-actions",
256256+ form { class: "hero-band-signin", action: "/oauth/start", method: "get",
257257+ input { r#type: "hidden", name: "return_to", value: "/" }
258258+ input {
259259+ class: "hero-band-input",
260260+ r#type: "text",
261261+ name: "identifier",
262262+ placeholder: "you.bsky.social",
263263+ aria_label: "Bluesky handle",
264264+ }
265265+ button { class: "button button-secondary", r#type: "submit", "Sign in" }
266266+ }
267267+ Link { class: "hero-band-about", to: Route::About {}, "what's this?" }
268268+ }
269269+ }
270270+ div { class: "hero-band-dismiss", dangerous_inner_html: dismiss_html }
271271+ }
272272+ }
273273+}
274274+275275+#[server]
276276+async fn hero_dismissed_seed() -> Result<bool, ServerFnError> {
277277+ use axum_extra::extract::cookie::CookieJar;
278278+ use dioxus::prelude::dioxus_fullstack::FullstackContext;
279279+280280+ let context = FullstackContext::current()
281281+ .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?;
282282+ let parts = context.parts_mut().clone();
283283+ let jar = CookieJar::from_headers(&parts.headers);
284284+ Ok(jar.get(HERO_DISMISS_COOKIE).is_some())
285285+}
286286+153287#[cfg(test)]
154288mod tests {
155289 use super::*;
···170304 BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3),
171305 state => panic!("expected populated state, got {state:?}"),
172306 }
307307+ }
308308+309309+ #[test]
310310+ fn hero_shows_only_for_anonymous_and_undismissed() {
311311+ use crate::session::AuthenticatedIdentity;
312312+ use jacquard_common::types::string::{Did, Handle};
313313+314314+ assert!(should_show_hero(&SessionIdentity::Anonymous, None, false));
315315+ assert!(should_show_hero(
316316+ &SessionIdentity::Anonymous,
317317+ Some(&Ok(false)),
318318+ false
319319+ ));
320320+ // Either dismissal signal suppresses the band: the SSR seed...
321321+ assert!(!should_show_hero(
322322+ &SessionIdentity::Anonymous,
323323+ Some(&Ok(true)),
324324+ false
325325+ ));
326326+ // ...or the live client-read cookie (e.g. after client-side navigation).
327327+ assert!(!should_show_hero(
328328+ &SessionIdentity::Anonymous,
329329+ Some(&Ok(false)),
330330+ true
331331+ ));
332332+ // An unresolved session must not flash the band before identity lands.
333333+ assert!(!should_show_hero(&SessionIdentity::Unknown, None, false));
334334+335335+ let authed = SessionIdentity::Authenticated(AuthenticatedIdentity {
336336+ avatar: None,
337337+ did: Did::new_owned("did:plc:tester").unwrap(),
338338+ handle: Handle::new_owned("tester.test").unwrap(),
339339+ display_name: None,
340340+ });
341341+ assert!(!should_show_hero(&authed, None, false));
342342+ }
343343+344344+ #[test]
345345+ fn cookie_flag_detects_presence_only_among_other_cookies() {
346346+ assert!(cookie_has_flag("pm_hero_dismissed=1", HERO_DISMISS_COOKIE));
347347+ assert!(cookie_has_flag(
348348+ "session=abc; pm_hero_dismissed=1; theme=dark",
349349+ HERO_DISMISS_COOKIE
350350+ ));
351351+ assert!(!cookie_has_flag(
352352+ "session=abc; theme=dark",
353353+ HERO_DISMISS_COOKIE
354354+ ));
355355+ assert!(!cookie_has_flag("", HERO_DISMISS_COOKIE));
356356+ // Must not match a cookie whose name merely contains the flag.
357357+ assert!(!cookie_has_flag(
358358+ "not_pm_hero_dismissed=1",
359359+ HERO_DISMISS_COOKIE
360360+ ));
173361 }
174362}