# Polymodel Polymodel is an AT Protocol-native model/project sharing app — roughly Thingiverse on atproto ## Product direction Initial product shape: - model/project publishing on ATProto; - a small lexicon set, tentatively project, profile, and likes/saves; - feeds by profile, recent projects, and hot projects; - browser STL viewing as a core interaction; - Dioxus web UI backed by Rust and Jacquard 0.12. ## Source of truth - Jira project key: `PM`. - Confluence space key: `PM`; numeric space ID for page APIs: `98315`; overview/root page ID: `98479`. - Jira is both the human source of truth and the agent working truth for implementation tasks when Jira tools are available. - Confluence is the durable design and product-narrative space. - Local Markdown is supporting material unless a task explicitly says otherwise. ## VCS Use jj, not raw git, for normal development. This repo commits large model assets (multi-MiB STL/STEP under `public/models/`), so a fresh clone needs `jj config set --repo snapshot.max-new-file-size 5242880` before snapshotting new large files (jj's default 1 MiB cap refuses them); already-committed assets check out fine without it. - Create isolated work with jj workspaces, and immediately start a fresh change before editing: `jj new -m "PM-12: concise summary"`. - Use ticket-derived names like `pm-12-short-summary`. - Check your own changes with `jj diff` before reporting completion. - `main` usually marks the default workspace head — the trunk merged work lands on. Merge a workspace's change back into the default line by rebasing it onto `main`, then advancing `main` and the default head: ```sh jj rebase -r @ -d main # rebase the workspace change onto main jj bookmark set main -r @ # advance main to the merged change jj new main -m ": ..." # open the next change on top; this also updates the default workspace head ``` After an external rebase the workspace's working copy is stale — run `jj workspace update-stale` inside that workspace before editing there again. Put follow-up edits in a fresh change on top of `main`, not piled onto a merged workspace change. ## Commands Use the justfile command surface: ```sh just fix just check just test just test-server just test-all just serve ``` Use Just recipes exclusively for all repository builds, checks, tests, generation, migrations, E2E, renderer, and oracle work. Invoke the command surface with plain `just [args]`; do not bypass it with raw `cargo`, `dx`, `npm`, `wasm-bindgen`, or equivalent commands. When a required operation lacks a recipe, add the focused recipe first. Before review or handoff, run at least `just fix` and all relevant tests. Use `just test-all` for substantial changes. In a fresh checkout or jj workspace, run `just check` once before `just fix` so the workspace dependencies and generated build outputs are available. The frontend no longer generates or imports a `src/env.rs` module; browser configuration must not receive server-only `POLYMODEL_*` secrets. Server runtime configuration is loaded by the server modules from the process environment. ## Stack notes - Dioxus 0.7 is the UI framework. - Jacquard 0.12 is the AT Protocol client stack. - The project uses Rust 2024 on nightly with `wasm32-unknown-unknown` available. - For e2e tests, run `npm install` in the `./e2e` directory if it's the first time in the worktree, then run e2e tests. ### Renderer worker (PM-53) The 3D renderer (`three-d` + mesh parsing) runs in a **Web Worker** with its own WASM bundle, separate from the Dioxus app bundle. The app depends only on `polymodel-renderer-protocol` (a leaf crate with no heavy deps); `three-d`, `three-d-asset`, `stl_io`, and `polymodel-mesh` are **never** in the app's dependency tree. - **Run `just build-renderer-worker` before `dx serve`** (or rely on the `just serve` guard, which builds the worker if `public/renderer_worker_bg.wasm` is missing). Without the worker artifacts, the viewer route will fail silently. - **Worker artifacts:** `public/renderer_worker.js` + `public/renderer_worker_bg.wasm` are generated by `wasm-bindgen --target web`; `public/renderer_worker_loader.js` is hand-authored (the ES-module wrapper that calls `init()`). The `.js` and `.wasm` are gitignored; the loader is committed. - **Crate structure:** `polymodel-renderer-protocol` (leaf: `MeshFormat`/`LengthUnit`/`Units`/message types + postcard) → `polymodel-mesh` (STL/OBJ/glTF/3MF parsing, depends on `three-d-asset`) → `polymodel-renderer-worker` (`[[bin]]` wasm target, depends on `three-d` + `polymodel-mesh`). The app depends only on `polymodel-renderer-protocol`. - **Invariant:** `cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normal` must show zero `three-d`, `three-d-asset`, `stl_io`, or `polymodel-mesh` entries. `just verify-renderer-split` checks this. - **Build profile:** `worker-release` (inherits `release`, `opt-level = 3`, `lto = "fat"`, `panic = "abort"`) — the worker renders frames, so hot-path speed matters more than initial bytes. - **Supported viewer formats:** STL, OBJ, glTF (GLB/embedded/multi-file), and 3MF all parse client-side in the worker via `three-d-asset`. STEP converts server-side to glTF via opencascade.js. The `RemoteMeshParser` seam in `polymodel-mesh` is reserved for future server-side format additions. ### Front-end implementation notes - **Design guidance: use the `frontend-design` skill for any UI work** (Dioxus RSX, routes, `assets/styling/*.css`, layout/color/type/motion/state/copy). It carries the adapted Impeccable design method for Polymodel's committed blueprint identity and product register. Companions: `DESIGN.md` (repo root, the blueprint design system as machine-readable tokens mirroring `theme.css`), `tools/design-detector/` (deterministic no-install linter — `node tools/design-detector/detect.mjs --json assets/styling/` or a rendered route; scans CSS/HTML, not `.rs`), and Confluence [*Front-end design guidance*](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/655362) in space `PM` (durable human-facing version). - Dioxus asset declarations use source paths, but served asset URLs are flattened into `/assets/`. For example, declare a font with `asset!("/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", ...)`, but reference it from CSS as `url("/assets/ProzaLibre-Regular.woff2")`. Verify font and model asset paths in browser network output when changing asset declarations. - Keep generated lexicon/API code out of routine formatting churn. `crates/polymodel-api` is generated and is ignored by `rustfmt.toml`; do not include generated reformatting in ordinary feature slices unless the task is explicitly about regeneration or codegen output. - If a front-end slice needs a renderable primitive/demo surface, prefer a non-root route such as `/foundation`. Keep `/` as the product shell so implementation scaffolding does not become the primary product surface. - Use flex column for simple vertical page shells. Reserve CSS grid for actual two-dimensional layouts; grid containers combined with `min-height: 100vh` can create surprising stretched rows and large visual gaps. - During visual/layout debugging, use screenshots plus accessibility snapshots with boxes and computed-style inspection. Screenshots show symptoms; boxes/computed styles identify the CSS rule causing the layout. - The app renders every route inside a single `#[layout(AppShell)]` shell (`src/shell.rs`) with an `Outlet`; the shell owns the global header (wordmark, Browse, global search → `/search`, Publish CTA, session/account control) and the viewport height (`min-height: 100dvh`, flex column), so pages are `flex: 1` and never set `min-height: 100vh`. Product nav deliberately excludes `/foundation` and `/viewer` (reachable by direct URL only). Sign-in `return_to` is the current route, not a hardcoded path. - `assets/styling/` follows a layered cascade, declared in that order in `src/main.rs`: tokens (`theme.css`) → reset/base (`base.css`) → reusable primitives (`primitives.css`) → shell chrome (`shell.css`) → per-route page styles (`home.css`, `thing-detail.css`, `profile.css`, `viewer.css`, `publish.css`, `placeholders.css`, `about.css`). Keep `theme.css` and `DESIGN.md` in sync (the design detector reads `DESIGN.md`). ### Lexicons & codegen - Authored lexicons live in root `lexicons/` (nested by area: `library/`, `actor/`, `graph/`), one JSON file per lexicon; the `id` field is authoritative. Design source: PM-6 brief ([Confluence 131433](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131433)). - `lexicons.kdl` (repo root) configures codegen output under `crates/polymodel-api/` (`src/`, `lexicons/`, `Cargo.toml`) with a `local` source for `./lexicons` and an unpinned `git` source for `com.atproto.*` (tracks upstream atproto on regen, like vodplace). - `crates/polymodel-api` is **generated** (DO-NOT-EDIT, no tests). Regenerate with `just generate-api` (`nix run ../jacquard`, the lex-fetch + codegen pipeline). Always fix authored JSON, never generated code. - The generated `Cargo.toml` keeps human content (package, `[workspace.dependencies]`-backed deps, lints, curated `default`/`streaming`/`serde` features) above a `# --- generated ---` marker; codegen rewrites only the namespace features below it. - Jacquard deps are declared once in root `[workspace.dependencies]` and referenced via `workspace = true` in both the app and the generated crate, giving a single jacquard version across the workspace. - ATProto has no float type: dimension fields (e.g. `bbox` x/y/z) are decimal strings parsed to floats in app code. Images use the `#image` (blobref) / `#imageView` (CDN URL) split in `space.polymodel.library.defs`. Polymodel `*View` lexicon types are appview/UI contracts: shape them around fields the UI needs, including required hydrated identity/author/count/viewer fields, instead of treating views as lossy transport objects and adding app-local adapter workarounds. Preserve typed ATProto values (`Handle`, `AtUri`, `Datetime`, URI types, etc.) once constructed; do not `.as_str()`/stringify and re-parse them through convenience constructors just to move them between fixtures, helpers, or components. Borrow typed values only at display/serialization boundaries. Keep record-authored fields on records unless a view/feed UI intentionally needs them directly; external or aggregated hydrated metadata such as display tags belongs on view types. - Part-file bytes are served via `space.polymodel.library.getPartFile` (`*/*` output, consistent with `com.atproto.sync.getBlob`): the server streams reassembled chunk blobs from the PDS without buffering the full file; digest and total-length verification are client-side (viewer mesh-load + direct download). ## Local projection: reads & writes The server keeps the browser single-origin: `space.polymodel.*` reads are served from the local SQLite projection, and a fixed allowlist of other XRPC methods is proxied to the user's PDS / public AppView (PM-47; design history in [PM-9 §4.5, Confluence 131467](https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/131467)). - **Writes to our own records are eagerly projected.** Every write the app performs to `space.polymodel.*` (PM-28 mediated writes *and* proxied `repo.createRecord`/`putRecord`/`deleteRecord`) is persisted to the local SQLite projection immediately after the user's PDS accepts it, for read-your-own-writes. Hydrant/firehose later re-delivers the same event; the shared projection rules keep that convergence idempotent. - **Reads hit the PDS/AppView but reflect pending local ops.** `repo.getRecord` of a `space.polymodel.*` record consults the `pending_writes`/`pending_deletes` tables while a local op awaits firehose confirmation (the request repo is canonicalized to a DID, so this holds for handle-authority reads): a `pending_writes` row not yet reflected by the PDS — a divergent cid, or the PDS reporting not-found — returns the local value/cid; a `pending_deletes` row returns `RecordNotFound` (HTTP 400) even if a lagging source still serves the record. The matching row is cleared once the Hydrant `#commit` re-delivers the event. - **Only `space.polymodel.*` is indexed.** Other records are proxied without local persistence. - **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. - **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. - **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**. ## Agent behavior - Investigate code and docs before asking factual questions. - Ask the operator for product intent and workflow tradeoffs. - Keep Jira updated at workflow gates. Do not restrict comment visibility — leave comments visible to all. ## Code style - **Prefer branded types over bare `String`/`&str`.** Any use of a bare `String` or `&str` where a wrapping, possibly-validated branded type exists (`Did`, `Handle`, `AtUri`, `Nsid`, `Cid`, `AtIdentifier`, …) is a bug to fix immediately unless clearly rebutted — e.g. binding to SQLite (TEXT columns), human-facing error/log messages, or where the surrounding error structure already carries the needed context. Thread the brand from the request/parse site down to the system boundary (DB bind, serialization) and convert with `.as_ref()`/`.as_str()` only there. Don't reconstruct a brand you already have (e.g. don't `Did::new_owned(s)` from a string you got by stringifying a `Did`), and prefer matching on a branded enum's variants over sniffing its serialized form (`ident.starts_with("did:")`). - Production SQL must use SQLx compile-time checked macros (`query!`, `query_as!`) rather than runtime `query`/`query_as` escape hatches. When changing production SQL or migrations, regenerate and commit the `.sqlx` cache with `just sqlx-prepare`.