atproto Thingiverse but good
0

Configure Feed

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

polymodel / AGENTS.md
15 kB

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:
    jj 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 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:

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-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 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, placeholders.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).
  • 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).

  • 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.