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 diffbefore reporting completion. mainusually marks the default workspace head — the trunk merged work lands on. Merge a workspace's change back into the default line by rebasing it ontomain, then advancingmainand the default head:
After an external rebase the workspace's working copy is stale — runjj rebase -r <workspace>@ -d main # rebase the workspace change onto main jj bookmark set main -r <workspace>@ # advance main to the merged change jj new main -m "<ticket>: ..." # open the next change on top; this also updates the default workspace headjj workspace update-staleinside that workspace before editing there again. Put follow-up edits in a fresh change on top ofmain, not piled onto a merged workspace change.
Commands#
Use the justfile command surface:
just fix
just check
just test
just test-server
just test-all
just serve
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: 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.
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-unknownavailable. - For e2e tests, run
npm installin the./e2edirectory 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-workerbeforedx serve(or rely on thejust serveguard, which builds the worker ifpublic/renderer_worker_bg.wasmis missing). Without the worker artifacts, the viewer route will fail silently. - Worker artifacts:
public/renderer_worker.js+public/renderer_worker_bg.wasmare generated bywasm-bindgen --target web;public/renderer_worker_loader.jsis hand-authored (the ES-module wrapper that callsinit()). The.jsand.wasmare 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 onthree-d-asset) →polymodel-renderer-worker([[bin]]wasm target, depends onthree-d+polymodel-mesh). The app depends only onpolymodel-renderer-protocol. - Invariant:
cargo tree -p polymodel --target wasm32-unknown-unknown --features web --edges normalmust show zerothree-d,three-d-asset,stl_io, orpolymodel-meshentries.just verify-renderer-splitchecks this. - Build profile:
worker-release(inheritsrelease,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. TheRemoteMeshParserseam inpolymodel-meshis reserved for future server-side format additions.
Front-end implementation notes#
- Design guidance: use the
frontend-designskill 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 mirroringtheme.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 in spacePM(durable human-facing version). - Dioxus asset declarations use source paths, but served asset URLs are flattened into
/assets/. For example, declare a font withasset!("/assets/fonts/proza-libre/ProzaLibre-Regular.woff2", ...), but reference it from CSS asurl("/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-apiis generated and is ignored byrustfmt.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: 100vhcan 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 anOutlet; 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 areflex: 1and never setmin-height: 100vh. Product nav deliberately excludes/foundationand/viewer(reachable by direct URL only). Sign-inreturn_tois the current route, not a hardcoded path. assets/styling/follows a layered cascade, declared in that order insrc/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,placeholders.css). Keeptheme.cssandDESIGN.mdin sync (the design detector readsDESIGN.md).
Lexicons & codegen#
- Authored lexicons live in root
lexicons/(nested by area:library/,actor/,graph/), one JSON file per lexicon; theidfield is authoritative. Design source: PM-6 brief (Confluence 131433). lexicons.kdl(repo root) configures codegen output undercrates/polymodel-api/(src/,lexicons/,Cargo.toml) with alocalsource for./lexiconsand an unpinnedgitsource forcom.atproto.*(tracks upstream atproto on regen, like vodplace).crates/polymodel-apiis generated (DO-NOT-EDIT, no tests). Regenerate withjust generate-api(nix run ../jacquard, the lex-fetch + codegen pipeline). Always fix authored JSON, never generated code.- The generated
Cargo.tomlkeeps human content (package,[workspace.dependencies]-backed deps, lints, curateddefault/streaming/serdefeatures) above a# --- generated ---marker; codegen rewrites only the namespace features below it. - Jacquard deps are declared once in root
[workspace.dependencies]and referenced viaworkspace = truein both the app and the generated crate, giving a single jacquard version across the workspace. - ATProto has no float type: dimension fields (e.g.
bboxx/y/z) are decimal strings parsed to floats in app code. Images use the#image(blobref) /#imageView(CDN URL) split inspace.polymodel.library.defs. Polymodel*Viewlexicon 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 withcom.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).
- Writes to our own records are eagerly projected. Every write the app performs to
space.polymodel.*(PM-28 mediated writes and proxiedrepo.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.getRecordof aspace.polymodel.*record consults thepending_writes/pending_deletestables while a local op awaits firehose confirmation (the request repo is canonicalized to a DID, so this holds for handle-authority reads): apending_writesrow not yet reflected by the PDS — a divergent cid, or the PDS reporting not-found — returns the local value/cid; apending_deletesrow returnsRecordNotFound(HTTP 400) even if a lagging source still serves the record. The matching row is cleared once the Hydrant#commitre-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 (
RecordNotFoundstays HTTP 400,InvalidSwapstays 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) plusidentity.resolveHandleandactor.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, reusingExtractOAuthSession+authenticated_did) backed by thedraftstable — not ATProto records and not new federated lexicons. A draft stores an all-optionalDraftThingInputpayload; publishing assembles it to a realThingInput, runspublish_compositionunderstate.write_lock, and deletes the draft only on success. This is the first browser-side authenticated mutation in the app — the wasmPolymodelClientrelies on the same-origin OAuth session cookie (no token handling). Accepted limitation:stageFileand 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 bareStringor&strwhere 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'tDid::new_owned(s)from a string you got by stringifying aDid), 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 runtimequery/query_asescape hatches. When changing production SQL or migrations, regenerate and commit the.sqlxcache withjust sqlx-prepare.