···9393- **Only `space.polymodel.*` is indexed.** Other records are proxied without local persistence.
9494- **Upstream errors are forwarded faithfully.** The passthrough surface forwards the real upstream HTTP status + error body verbatim (`RecordNotFound` stays HTTP 400, `InvalidSwap` stays 409, 429/5xx unchanged); only genuine transport/internal failures become 500.
9595- **The proxy allowlist is structural.** Non-`space.polymodel.*` methods are served only for the direct repo record operations (`createRecord`, `putRecord`, `deleteRecord`, `getRecord`, `listRecords`, `describeRepo`, `uploadBlob`) plus `identity.resolveHandle` and `actor.getProfile`, dispatched over their generated typed request structs through the OAuth session agent's signing send path. Batch/admin repo methods (`applyWrites`, `importRepo`, `listMissingBlobs`) and every other NSID have no route and 404.
9696+- **Server-side drafts are app-internal, not federated.** The publish wizard (PM-43) saves in-progress compositions as **owner-scoped server-side drafts** via app-internal authenticated appview endpoints (under `/app/drafts*` + `/app/images`, reusing `ExtractOAuthSession` + `authenticated_did`) backed by the `drafts` table — *not* ATProto records and *not* new federated lexicons. A draft stores an all-optional `DraftThingInput` payload; publishing assembles it to a real `ThingInput`, runs `publish_composition` under `state.write_lock`, and deletes the draft **only on success**. This is the **first browser-side authenticated mutation** in the app — the wasm `PolymodelClient` relies on the same-origin OAuth session cookie (no token handling). **Accepted limitation:** `stageFile` and the app-internal image-upload endpoint write blobs to the actor's PDS *during drafting* (only the records are deferred to publish); unreferenced blobs are PDS-GC'd. Deferring blob upload (server-side blob staging) + permissioned draft spaces is tracked in **PM-58**.
96979798## Agent behavior
9899
+15
Dioxus.toml
···11+[application]
22+name = "polymodel"
33+44+# Restrict the dev-server file watcher to source + styling. Without this, dx
55+# watches the whole crate directory and fires hot-patches/reloads on the
66+# firehose's continuous writes to ./data (SQLite projection) and ./hydrant.db
77+# (fjall store), which silently reset in-progress UI state (e.g. the publish
88+# wizard). Both dirs are gitignored but the dx 0.7 watcher does not honor
99+# .gitignore, so the watch set must be scoped explicitly.
1010+[web.watcher]
1111+watch_path = ["src", "assets"]
1212+reload_html = true
1313+# Serve the root document for unknown routes so client-side router deep links
1414+# (e.g. /publish, /:repo/thing/:rkey) resolve on direct navigation.
1515+index_on_404 = true
···404404 .ok_or_else(|| unauthorized("OAuth session is required"))
405405}
406406407407+/// Ensure the actor's handle is present in the `identities` projection, mirroring
408408+/// what a firehose `#identity` event would write. No-op when already projected;
409409+/// otherwise resolves the handle (local cache, then DID document) and upserts it.
410410+async fn ensure_actor_identity(state: &AppState, actor: &Did) -> AppResult<()> {
411411+ if views::handle_for_did(state, actor).await?.is_some() {
412412+ return Ok(());
413413+ }
414414+ let handle = views::resolve_handle(state, actor).await?;
415415+ let now = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
416416+ let actor_ref = actor.as_ref();
417417+ db(sqlx::query!(
418418+ "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?) \
419419+ ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, updated_at = excluded.updated_at",
420420+ actor_ref,
421421+ handle,
422422+ now,
423423+ )
424424+ .execute(&state.pool)
425425+ .await)?;
426426+ Ok(())
427427+}
428428+407429pub(super) async fn ensure_polymodel_profile(
408430 state: &AppState,
409431 agent: &UserAgent,
410432 actor: &Did,
411433 allow_create: bool,
412434) -> AppResult<BootstrapOutcome> {
435435+ // Read-your-own-writes: record the actor's handle in the identity projection
436436+ // so author hydration on an immediate read doesn't 500 (the firehose is
437437+ // otherwise the only writer of `identities`, and it lags a fresh sign-in).
438438+ ensure_actor_identity(state, actor).await?;
439439+413440 if let Some(profile) = views::polymodel_profile_view(state, actor).await? {
414441 return Ok(BootstrapOutcome {
415442 profile,
···420447 }
421448422449 let profile_uri = at_uri_owned(actor, PROFILE_COLLECTION, "self")?;
423423- match agent.get_record::<PolymodelProfile, _>(&profile_uri).await {
424424- Ok(resp) => {
425425- let output = resp
426426- .into_output()
427427- .map_err(|e| internal(format!("profile record decode failed: {e}")))?;
428428- eager_project_record(
429429- state,
430430- actor,
431431- PROFILE_COLLECTION,
432432- "self",
433433- output.cid.clone(),
434434- &output.value,
435435- "create",
436436- )
437437- .await?;
438438- let profile = views::polymodel_profile_view(state, actor)
439439- .await?
440440- .ok_or_else(|| internal("projected profile was not readable"))?;
441441- return Ok(BootstrapOutcome {
442442- profile,
443443- status: BootstrapProfileOutputStatus::ExistingRemote,
444444- uri: Some(output.uri),
445445- cid: output.cid,
446446- });
447447- }
448448- Err(e) if client_error_is_not_found(&e) => {}
450450+ // A missing record surfaces as an XRPC `RecordNotFound` decoded by
451451+ // `into_output()`, not as a transport-level `Err` — so a not-found must be
452452+ // caught in both arms or it becomes a spurious 500 "decode failed".
453453+ let existing_remote = match agent.get_record::<PolymodelProfile, _>(&profile_uri).await {
454454+ Ok(resp) => match resp.into_output() {
455455+ Ok(output) => Some(output),
456456+ Err(e) if display_is_not_found(&e) => None,
457457+ Err(e) => return Err(internal(format!("profile record decode failed: {e}"))),
458458+ },
459459+ Err(e) if client_error_is_not_found(&e) => None,
449460 Err(e) => return Err(client_error("fetch polymodel profile", e)),
461461+ };
462462+ if let Some(output) = existing_remote {
463463+ eager_project_record(
464464+ state,
465465+ actor,
466466+ PROFILE_COLLECTION,
467467+ "self",
468468+ output.cid.clone(),
469469+ &output.value,
470470+ "create",
471471+ )
472472+ .await?;
473473+ let profile = views::polymodel_profile_view(state, actor)
474474+ .await?
475475+ .ok_or_else(|| internal("projected profile was not readable"))?;
476476+ return Ok(BootstrapOutcome {
477477+ profile,
478478+ status: BootstrapProfileOutputStatus::ExistingRemote,
479479+ uri: Some(output.uri),
480480+ cid: output.cid,
481481+ });
450482 }
451483452484 if !allow_create {
···457489458490 let bsky_uri = at_uri_owned(actor, "app.bsky.actor.profile", "self")?;
459491 let bsky = match agent.get_record::<BskyProfile, _>(&bsky_uri).await {
460460- Ok(resp) => {
461461- resp.into_output()
462462- .map_err(|e| internal(format!("bsky profile decode failed: {e}")))?
463463- .value
464464- }
465465- Err(e) if client_error_is_not_found(&e) => BskyProfile {
466466- avatar: None,
467467- banner: None,
468468- created_at: None,
469469- description: None,
470470- display_name: None,
471471- joined_via_starter_pack: None,
472472- labels: None,
473473- pinned_post: None,
474474- pronouns: None,
475475- website: None,
476476- extra_data: None,
492492+ Ok(resp) => match resp.into_output() {
493493+ Ok(output) => output.value,
494494+ Err(e) if display_is_not_found(&e) => empty_bsky_profile(),
495495+ Err(e) => return Err(internal(format!("bsky profile decode failed: {e}"))),
477496 },
497497+ Err(e) if client_error_is_not_found(&e) => empty_bsky_profile(),
478498 Err(e) => return Err(client_error("fetch bsky profile", e)),
479499 };
480500···15411561fn client_error_is_not_found(err: &jacquard_common::error::ClientError) -> bool {
15421562 let text = format!("{err:?}");
15431563 text.contains("RecordNotFound") || text.contains("not found") || text.contains("404")
15641564+}
15651565+15661566+/// A `getRecord` for a missing record can come back as a successful HTTP
15671567+/// response whose body decodes (via `into_output()`) to an XRPC `RecordNotFound`
15681568+/// error rather than a transport-level `Err`. Detect that from the error's
15691569+/// `Display` so the caller can treat the record as absent instead of failing.
15701570+fn display_is_not_found<E: std::fmt::Display>(err: &E) -> bool {
15711571+ let text = err.to_string();
15721572+ text.contains("RecordNotFound") || text.contains("not found") || text.contains("404")
15731573+}
15741574+15751575+/// An empty `app.bsky.actor.profile` used when an actor has no Bluesky profile
15761576+/// record to copy basics from during polymodel profile bootstrap.
15771577+fn empty_bsky_profile() -> BskyProfile {
15781578+ BskyProfile {
15791579+ avatar: None,
15801580+ banner: None,
15811581+ created_at: None,
15821582+ description: None,
15831583+ display_name: None,
15841584+ joined_via_starter_pack: None,
15851585+ labels: None,
15861586+ pinned_post: None,
15871587+ pronouns: None,
15881588+ website: None,
15891589+ extra_data: None,
15901590+ }
15441591}
1545159215461593fn agent_error_is_auth(err: &jacquard::client::AgentError) -> bool {
+21-4
src/main.rs
···11use dioxus::prelude::ServerFnError;
22use dioxus::prelude::*;
33use jacquard_common::xrpc::XrpcClient;
44+use polymodel_api::space_polymodel::actor::bootstrap_profile::BootstrapProfile;
45use polymodel_api::space_polymodel::actor::get_session::{GetSession, GetSessionOutput};
5667mod auth;
···137138#[component]
138139fn App() -> Element {
139140 let client = use_context_provider(PolymodelClient::default);
140140- let mut session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous));
141141+ let mut session = use_context_provider(|| Signal::new(SessionIdentity::Unknown));
141142 let toasts = use_context_provider(|| Signal::new(auth::ToastState::default()));
142143143144 let ssr_session_seed = use_server_future(server_session_seed)?;
···155156 }
156157 match client.send(GetSession).await {
157158 Ok(response) => match response.into_output() {
158158- Ok(output) => session.set(SessionIdentity::from_get_session(output)),
159159- Err(error) => tracing::warn!(%error, "failed to decode Polymodel session"),
159159+ Ok(output) => {
160160+ let identity = SessionIdentity::from_get_session(output);
161161+ let authenticated = matches!(identity, SessionIdentity::Authenticated(_));
162162+ session.set(identity);
163163+ // First-auth profile bootstrap: create a polymodel profile from
164164+ // Bluesky basics if the signed-in actor has none yet. Idempotent
165165+ // server-side (returns the existing profile when present).
166166+ if authenticated && let Err(error) = client.send(BootstrapProfile).await {
167167+ tracing::warn!(%error, "failed to bootstrap Polymodel profile");
168168+ }
169169+ }
170170+ Err(error) => {
171171+ tracing::warn!(%error, "failed to decode Polymodel session");
172172+ session.set(SessionIdentity::Anonymous);
173173+ }
160174 },
161161- Err(error) => tracing::warn!(%error, "failed to fetch Polymodel session"),
175175+ Err(error) => {
176176+ tracing::warn!(%error, "failed to fetch Polymodel session");
177177+ session.set(SessionIdentity::Anonymous);
178178+ }
162179 }
163180 }
164181 });
+9-33
src/profile.rs
···44use jacquard_common::types::string::{Did, Handle, UriValue};
55use jacquard_common::xrpc::XrpcClient;
66use polymodel_api::space_polymodel::actor::{
77- ProfileViewUnion, get_profile::GetProfile, get_session::GetSession,
77+ ProfileViewUnion, get_profile::GetProfile,
88};
99use polymodel_api::space_polymodel::library;
1010use polymodel_api::space_polymodel::library::get_author_things::GetAuthorThings;
···228228#[allow(clippy::useless_format)]
229229#[component]
230230pub(crate) fn ProfileSelf() -> Element {
231231- let client = use_context::<PolymodelClient>();
232231 let session = use_context::<Signal<SessionIdentity>>();
233233- // Sign-in must return the user to the page they started on; here that is
234234- // the current profile route rather than a hardcoded path.
232232+ // Sign-in returns the user to the page they started on, not a hardcoded path.
235233 let return_to = use_route::<Route>().to_string();
236236- // Re-check the session on mount — the App-level seed runs once and may have
237237- // failed or not resolved yet. This ensures /profile always has a fresh read.
238238- let session_check = use_resource(move || {
239239- let client = client.clone();
240240- let mut session = session;
241241- async move {
242242- match client.send(GetSession).await {
243243- Ok(response) => match response.into_output() {
244244- Ok(output) => {
245245- let identity = SessionIdentity::from_get_session(output);
246246- session.set(identity);
247247- }
248248- Err(error) => {
249249- tracing::warn!(error = ?error, "profile: failed to decode session");
250250- }
251251- },
252252- Err(error) => {
253253- tracing::warn!(error = ?error, "profile: failed to fetch session");
254254- }
255255- }
256256- }
257257- });
258234259259- if session_check.pending() {
260260- return rsx! {
235235+ // Reuse the shared session resolution from the App/shell: `Unknown` is the
236236+ // brief in-flight state (the App resolves it to a terminal Authenticated/
237237+ // Anonymous even on failure), so /profile needs no per-page session re-check.
238238+ match session.read().clone() {
239239+ SessionIdentity::Unknown => rsx! {
261240 main { class: "profile-page product-shell",
262241 section { class: "profile-shell blueprint-panel",
263242 h1 { class: "sr-only", "Loading profile" }
···269248 }
270249 }
271250 }
272272- };
273273- }
274274-275275- match session.read().clone() {
251251+ },
276252 SessionIdentity::Authenticated(auth) => {
277253 rsx! { ProfileBody { actor: auth.handle.as_str().to_string() } }
278254 }
···349325 SessionIdentity::Authenticated(identity) => {
350326 (identity.did == data.header.did, false)
351327 }
352352- SessionIdentity::Anonymous => (false, true),
328328+ SessionIdentity::Anonymous | SessionIdentity::Unknown => (false, true),
353329 };
354330 rsx! {
355331 ProfilePopulated { data, is_own, is_anonymous, active_tab }
+134-93
src/publish.rs
···2525];
2626const DEFAULT_LICENSE: &str = "CC-BY-4.0";
27272828+/// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's
2929+/// `blob_cdn_url`) so the wizard can preview an image right after upload.
3030+fn image_cdn_url(did: &str, image: &Image) -> String {
3131+ let cid = image.image.blob().r#ref.as_str();
3232+ format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg")
3333+}
3434+2835/// Transient state of a single in-flight raw upload (file or image).
2936#[derive(Clone, PartialEq)]
3037enum UploadStatus {
···3643/// Whether the server-side draft is in sync with the in-memory composition.
3744#[derive(Clone, Copy, PartialEq)]
3845enum SaveState {
4646+ /// Initial state: nothing saved yet, so the status reads as empty rather than
4747+ /// falsely claiming everything is saved.
4848+ New,
3949 Saved,
4050 Saving,
4151 Dirty,
···4959#[component]
5060pub(crate) fn Publish(draft: String) -> Element {
5161 let session = use_context::<Signal<SessionIdentity>>();
5252- let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous) && false; // TEMP-SCREENSHOT-BYPASS
6262+ let identity = session.read().clone();
53635454- if is_anonymous {
6464+ // Session still resolving on first load: show a neutral loading state rather
6565+ // than flashing the signed-out callout at users who are actually signed in.
6666+ if matches!(identity, SessionIdentity::Unknown) {
5567 return rsx! {
5668 main { class: "publish-page",
5769 section { class: "publish-intro blueprint-panel", aria_label: "Publish",
5858- h1 { "Publish a thing" }
5959- p { class: "product-lede", "Assemble a model — files, images, instructions, and a license — and publish it to your ATProto repository." }
7070+ h1 { "Publish" }
7171+ div { class: "skeleton-line skeleton-wide" }
7272+ div { class: "skeleton-line" }
6073 }
7474+ }
7575+ };
7676+ }
7777+7878+ if matches!(identity, SessionIdentity::Anonymous) {
7979+ return rsx! {
8080+ main { class: "publish-page",
6181 section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish",
6262- span { class: "status-pill", "Signed out" }
6363- h2 { "Sign in to publish" }
6464- p { "Publishing needs an ATProto account. Sign in and you return right here to start drafting." }
8282+ h1 { "Share your models" }
8383+ p { class: "product-lede", "Sign in to upload your files and publish under your handle." }
6584 form {
6685 class: "session-control",
6786 action: "/oauth/start",
···7190 r#type: "text",
7291 name: "identifier",
7392 placeholder: "handle.test",
7474- aria_label: "ATProto handle for publishing",
9393+ aria_label: "ATProto handle",
7594 }
7695 button { class: "button button-primary", r#type: "submit", "Sign in" }
7796 }
···96115 upload: Signal<UploadStatus>,
97116 image_upload: Signal<UploadStatus>,
98117 drafts: Signal<Vec<DraftSummary>>,
118118+ /// Authenticated actor DID, used to build CDN preview URLs for uploaded images.
119119+ did: Option<String>,
99120}
100121101122#[component]
102123fn PublishWizard(resume: String) -> Element {
103124 let client = use_context::<PolymodelClient>();
104104- let composition = use_signal(DraftThingInput::default);
125125+ let session = use_context::<Signal<SessionIdentity>>();
126126+ let did = match &*session.read() {
127127+ SessionIdentity::Authenticated(identity) => Some(identity.did.as_str().to_string()),
128128+ _ => None,
129129+ };
130130+ // Seed a new draft with the effective default license so the control reflects
131131+ // real state from the start (a control only fires change when its value changes).
132132+ let composition = use_signal(|| DraftThingInput {
133133+ license: Some(DEFAULT_LICENSE.to_string()),
134134+ ..DraftThingInput::default()
135135+ });
105136 let draft_id = use_signal(|| Option::<String>::None);
106137 let step = use_signal(|| WizardStep::Basics);
107138 let phase = use_signal(|| PublishPhase::Editing);
108108- let save = use_signal(|| SaveState::Saved);
139139+ let save = use_signal(|| SaveState::New);
109140 let upload = use_signal(|| UploadStatus::Idle);
110141 let image_upload = use_signal(|| UploadStatus::Idle);
111142 let drafts = use_signal(Vec::<DraftSummary>::new);
···120151 upload,
121152 image_upload,
122153 drafts,
154154+ did,
123155 };
124156125157 // Resume a draft once on mount when `?draft=<id>` is present; always load
···148180 });
149181 }
150182183183+ // Mirror the active draft id into the URL so a full reload (e.g. the dev
184184+ // server's hot-reload websocket dropping and reconnecting) resumes the same
185185+ // draft via `?draft=<id>` instead of dropping you on a blank `/publish`.
186186+ // replace() avoids stacking a history entry on every autosave.
187187+ {
188188+ let draft_id = wizard.draft_id;
189189+ use_effect(move || {
190190+ if let Some(id) = draft_id.read().clone() {
191191+ navigator().replace(Route::Publish { draft: id });
192192+ }
193193+ });
194194+ }
195195+151196 let current = *wizard.step.read();
152197 let body = match current {
153198 WizardStep::Basics => wizard.render_basics(),
···325370 PublishPhase::Published { .. } => ("status-pill", "Published"),
326371 PublishPhase::Publishing => ("status-pill", "Publishing…"),
327372 PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"),
328328- PublishPhase::Editing => ("status-pill status-muted", "Draft — not public yet"),
373373+ PublishPhase::Editing => ("status-pill status-muted", "Draft"),
329374 };
330375 let save_label = match save {
331331- SaveState::Saved => "All changes saved",
376376+ SaveState::New => "",
377377+ SaveState::Saved => "Saved",
332378 SaveState::Saving => "Saving…",
333379 SaveState::Dirty => "Unsaved changes",
334334- SaveState::Error => "Could not save draft",
380380+ SaveState::Error => "Save failed",
335381 };
336382 rsx! {
337383 header { class: "publish-head",
···429475 let composition = self.composition.read();
430476 let name = composition.name.clone().unwrap_or_default();
431477 let summary = composition.summary.clone().unwrap_or_default();
432432- let license = composition
433433- .license
434434- .clone()
435435- .unwrap_or_else(|| DEFAULT_LICENSE.to_string());
436436- let tags = composition.tags.clone().unwrap_or_default().join(", ");
437478 drop(composition);
438479439480 let name_w = self.clone();
440481 let summary_w = self.clone();
441441- let license_w = self.clone();
442442- let tags_w = self.clone();
443482444483 rsx! {
445484 h2 { "Basics" }
446446- p { class: "wizard-step-hint", "Name your thing and pick a license. You can change everything before you publish." }
447485 label { class: "field",
448486 span { class: "field-label", "Name" }
449487 input {
···463501 textarea {
464502 class: "field-input field-textarea",
465503 value: "{summary}",
466466- placeholder: "A short description of what this is.",
504504+ placeholder: "What is it?",
467505 oninput: move |evt| {
468506 let mut c = summary_w.composition;
469507 let v = evt.value();
···472510 },
473511 }
474512 }
475475- label { class: "field",
476476- span { class: "field-label", "License" }
477477- select {
478478- class: "field-input",
479479- value: "{license}",
480480- onchange: move |evt| {
481481- let mut c = license_w.composition;
482482- c.write().license = Some(evt.value());
483483- license_w.mark_dirty();
484484- },
485485- for option in LICENSE_OPTIONS {
486486- option { key: "{option}", value: "{option}", selected: *option == license, "{option}" }
487487- }
488488- }
489489- span { class: "field-help card-meta", "Effective license: {license}" }
490490- }
491491- label { class: "field",
492492- span { class: "field-label", "Tags" }
493493- input {
494494- class: "field-input",
495495- r#type: "text",
496496- value: "{tags}",
497497- placeholder: "comma, separated, tags",
498498- oninput: move |evt| {
499499- let mut c = tags_w.composition;
500500- let parsed: Vec<String> = evt
501501- .value()
502502- .split(',')
503503- .map(|t| t.trim().to_string())
504504- .filter(|t| !t.is_empty())
505505- .collect();
506506- c.write().tags = if parsed.is_empty() { None } else { Some(parsed) };
507507- tags_w.mark_dirty();
508508- },
509509- }
510510- }
511513 }
512514 }
513515···699701 let preview_w = self.clone();
700702 rsx! {
701703 h2 { "Images" }
702702- p { class: "wizard-step-hint", "Upload a cover image and optional preview images. Each needs alt text for accessibility." }
703704 match &status {
704705 UploadStatus::Uploading(label) => rsx! {
705706 div { class: "wizard-upload-status", role: "status",
···759760 {
760761 let alt = image.alt.to_string();
761762 let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height);
763763+ let preview_url = self.did.as_ref().map(|did| image_cdn_url(did, image));
762764 let alt_w = self.clone();
763765 let remove_w = self.clone();
764766 rsx! {
765767 li { key: "image-{is_cover}-{i}", class: "wizard-image",
766768 div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true",
767767- span { class: "blueprint-dimension", "{ratio}" }
769769+ if let Some(url) = preview_url {
770770+ img { class: "wizard-image-img", src: "{url}", alt: "" }
771771+ } else {
772772+ span { class: "blueprint-dimension", "{ratio}" }
773773+ }
768774 }
769775 label { class: "field wizard-image-alt",
770776 span { class: "field-label", "Alt text" }
···864870 let tags = composition.tags.clone().unwrap_or_default();
865871 drop(composition);
866872873873+ let tags = tags.join(", ");
874874+ let license_w = self.clone();
875875+ let tags_w = self.clone();
867876 let instructions_w = self.clone();
868877 rsx! {
869869- h2 { "Instructions & license" }
870870- p { class: "wizard-step-hint", "Add assembly or printing instructions. One step per line." }
878878+ h2 { "Details" }
879879+ label { class: "field",
880880+ span { class: "field-label", "License" }
881881+ input {
882882+ class: "field-input",
883883+ r#type: "text",
884884+ list: "license-options",
885885+ value: "{license}",
886886+ placeholder: "{DEFAULT_LICENSE}",
887887+ oninput: move |evt| {
888888+ let mut c = license_w.composition;
889889+ let v = evt.value();
890890+ c.write().license = if v.trim().is_empty() { None } else { Some(v) };
891891+ license_w.mark_dirty();
892892+ },
893893+ }
894894+ datalist { id: "license-options",
895895+ for option in LICENSE_OPTIONS {
896896+ option { key: "{option}", value: "{option}" }
897897+ }
898898+ }
899899+ }
900900+ label { class: "field",
901901+ span { class: "field-label", "Tags" }
902902+ input {
903903+ class: "field-input",
904904+ r#type: "text",
905905+ value: "{tags}",
906906+ placeholder: "comma, separated",
907907+ oninput: move |evt| {
908908+ let mut c = tags_w.composition;
909909+ let parsed: Vec<String> = evt
910910+ .value()
911911+ .split(',')
912912+ .map(|t| t.trim().to_string())
913913+ .filter(|t| !t.is_empty())
914914+ .collect();
915915+ c.write().tags = if parsed.is_empty() { None } else { Some(parsed) };
916916+ tags_w.mark_dirty();
917917+ },
918918+ }
919919+ }
871920 label { class: "field",
872921 span { class: "field-label", "Instructions" }
873922 textarea {
874923 class: "field-input field-textarea field-textarea-tall",
875924 value: "{instructions}",
876876- placeholder: "One instruction per line.",
925925+ placeholder: "One step per line",
877926 oninput: move |evt| {
878927 let mut c = instructions_w.composition;
879879- let lines: Vec<String> = evt
880880- .value()
881881- .lines()
882882- .map(|l| l.trim_end().to_string())
883883- .filter(|l| !l.trim().is_empty())
884884- .collect();
885885- c.write().instructions = if lines.is_empty() { None } else { Some(lines) };
928928+ let value = evt.value();
929929+ // Store the text exactly as typed (split only on newlines) so
930930+ // spaces and blank lines survive editing; empty lines are
931931+ // dropped at assembly.
932932+ let lines: Vec<String> = value.split('\n').map(str::to_string).collect();
933933+ c.write().instructions = if value.trim().is_empty() { None } else { Some(lines) };
886934 instructions_w.mark_dirty();
887935 },
888936 }
889937 }
890890- div { class: "wizard-recap",
891891- span { class: "field-label", "License" }
892892- span { class: "status-pill", "{license}" }
893893- }
894894- div { class: "wizard-recap",
895895- span { class: "field-label", "Tags" }
896896- if tags.is_empty() {
897897- span { class: "card-meta", "None" }
898898- } else {
899899- div { class: "thing-card-chips",
900900- for tag in tags {
901901- span { key: "{tag}", class: "status-pill", "{tag}" }
902902- }
903903- }
904904- }
905905- }
906938 }
907939 }
908940···925957926958 let publishing = matches!(*self.phase.read(), PublishPhase::Publishing);
927959 let issues = assembled.as_ref().err().cloned();
928928- let name_error = issues.as_ref().map(|e| e.has_field("name")).unwrap_or(false);
960960+ let name_error = issues
961961+ .as_ref()
962962+ .map(|e| e.has_field("name"))
963963+ .unwrap_or(false);
929964 let license_error = issues
930965 .as_ref()
931966 .map(|e| e.has_field("license"))
···945980946981 rsx! {
947982 h2 { "Review & publish" }
948948- p { class: "wizard-step-hint", "This is what will be public when you publish. Nothing is published until you choose to." }
983983+ p { class: "wizard-step-hint", "This is what becomes public when you publish." }
949984950985 div { class: "wizard-review",
951986 div { class: "{name_row_class}",
···10111046 }
1012104710131048 div { class: "wizard-publish-row",
10141014- span { class: "status-pill status-muted", "Draft — not public yet" }
10151049 button {
10161050 class: "button button-primary wizard-publish",
10171051 disabled: !can_publish,
···10191053 let wizard = publish_w.clone();
10201054 spawn(async move { wizard.publish().await });
10211055 },
10221022- if publishing { "Publishing…" } else { "Publish to your PDS" }
10561056+ if publishing { "Publishing…" } else { "Publish" }
10231057 }
10241058 }
10251059 }
10261060 }
1027106110281062 async fn publish(self) {
10291029- // Ensure the latest edits are persisted, then publish the saved draft.
10631063+ // Persist the latest edits first. If that save fails, do NOT publish —
10641064+ // otherwise we would publish stale server-side draft state.
10301065 self.clone().save_draft().await;
10661066+ if matches!(*self.save.read(), SaveState::Error) {
10671067+ let mut phase = self.phase;
10681068+ phase.set(PublishPhase::Failed(
10691069+ "Could not save your latest changes, so nothing was published. Try again.".into(),
10701070+ ));
10711071+ return;
10721072+ }
10311073 let Some(id) = self.draft_id.read().clone() else {
10321074 let mut phase = self.phase;
10331075 phase.set(PublishPhase::Failed(
···10551097 section { class: "wizard-published state-card", aria_label: "Published",
10561098 span { class: "status-pill", "Published" }
10571099 h2 { "Your thing is live" }
10581058- p { "It was published to your repository and is now public." }
10591100 Link {
10601101 class: "button button-primary",
10611102 to: Route::ThingDetail { repo, rkey },
10621062- "View your published thing"
11031103+ "View it"
10631104 }
10641105 }
10651106 }
+35-8
src/publish/draft.rs
···225225 .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>())
226226}
227227228228+/// Convert a freeform multi-line text field (stored raw, one entry per line)
229229+/// into a list of non-empty steps. Whitespace-only lines are dropped here, at
230230+/// assembly, so the live textarea can keep spaces and blank lines while typing.
231231+fn clean_lines(value: &Option<Vec<String>>) -> Option<Vec<S>> {
232232+ let lines: Vec<S> = value
233233+ .as_ref()?
234234+ .iter()
235235+ .map(|line| line.trim_end())
236236+ .filter(|line| !line.trim().is_empty())
237237+ .map(S::from)
238238+ .collect();
239239+ if lines.is_empty() { None } else { Some(lines) }
240240+}
241241+228242impl DraftPartInput {
229243 /// A part is backed by a staged file when it has either an inline manifest
230244 /// or a non-empty upload id.
···262276 self.models.len() as i64
263277 }
264278265265- /// Step 1 (Basics) is complete when name and license are both set.
279279+ /// Basics is complete once the thing has a name. License has an effective
280280+ /// default and is chosen in the details step, so it never gates Basics.
266281 pub fn basics_complete(&self) -> bool {
267267- nonempty(&self.name) && nonempty(&self.license)
282282+ nonempty(&self.name)
268283 }
269284270285 /// Step 2 (Files & parts) is complete when there is at least one model and
···284299285300 if !nonempty(&self.name) {
286301 errors.push("name", "A name is required to publish.");
287287- }
288288- if !nonempty(&self.license) {
289289- errors.push("license", "A license is required to publish.");
290302 }
291303 if self.models.is_empty() {
292304 errors.push("models", "Add at least one model with a file.");
···364376 let thing = ThingInput {
365377 cover: self.cover.clone(),
366378 derived_from: self.derived_from.clone(),
367367- instructions: opt_vec_s(&self.instructions),
379379+ instructions: clean_lines(&self.instructions),
368380 intended_to_work_with: self.intended_to_work_with.clone(),
369369- license: into_s(self.license.as_deref().unwrap_or_default()),
381381+ license: into_s(
382382+ self.license
383383+ .as_deref()
384384+ .map(str::trim)
385385+ .filter(|v| !v.is_empty())
386386+ .unwrap_or(super::DEFAULT_LICENSE),
387387+ ),
370388 models,
371389 name: into_s(self.name.as_deref().unwrap_or_default()),
372390 previews: self.previews.clone(),
···475493 let draft = DraftThingInput::default();
476494 let errors = draft.assemble().expect_err("empty draft fails");
477495 assert!(errors.has_field("name"));
478478- assert!(errors.has_field("license"));
479496 assert!(errors.has_field("models"));
497497+ // License is not required: it defaults at assembly, never an error.
498498+ assert!(!errors.has_field("license"));
499499+ }
500500+501501+ #[test]
502502+ fn unset_license_defaults_at_assembly() {
503503+ let mut draft = complete_draft();
504504+ draft.license = None;
505505+ let thing = draft.assemble().expect("unset license defaults");
506506+ assert_eq!(thing.license.as_str(), "CC-BY-4.0");
480507 }
481508482509 #[test]
+4
src/session.rs
···44use polymodel_api::space_polymodel::actor::get_session::{GetSessionOutput, SessionView};
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
66pub enum SessionIdentity {
77+ /// Session not yet determined — the initial state before `getSession`
88+ /// resolves. Distinct from `Anonymous` so the UI can show a loading state
99+ /// instead of flashing signed-out chrome at users who are actually signed in.
710 #[default]
1111+ Unknown,
812 Anonymous,
913 Authenticated(AuthenticatedIdentity),
1014}
+3
src/shell.rs
···9797 let return_to = current_return_to();
98989999 match identity {
100100+ // Session still resolving: render nothing rather than flashing a sign-in
101101+ // form at users who turn out to be authenticated.
102102+ SessionIdentity::Unknown => rsx! {},
100103 SessionIdentity::Anonymous => rsx! {
101104 form {
102105 class: "session-control",
-1
src/thing_detail.rs
···260260261261 rsx! {
262262 header { class: "thing-detail-header",
263263- div { class: "product-kicker", "Thing detail" }
264263 div { class: "thing-detail-title-row",
265264 div {
266265 h1 { "{title}" }